【并发编程】synchronized的使用场景和原理简介

1. synchronized使用

1.1 synchronized介绍

在多线程并发编程中synchronized一直是元老级角色,不少人都会称呼它为重量级锁。可是,随着Java SE 1.6对synchronized进行了各类优化以后,有些状况下它就并不那么重了。java

synchronized能够修饰普通方法,静态方法和代码块。当synchronized修饰一个方法或者一个代码块的时候,它可以保证在同一时刻最多只有一个线程执行该段代码。数据库

  • 对于普通同步方法,锁是当前实例对象(不一样实例对象之间的锁互不影响)。编程

  • 对于静态同步方法,锁是当前类的Class对象。数组

  • 对于同步方法块,锁是Synchonized括号里配置的对象。安全

当一个线程试图访问同步代码块时,它首先必须获得锁,退出或抛出异常时必须释放锁。多线程

1.2 使用场景

synchronized最经常使用的使用场景就是多线程并发编程时线程的同步。这边仍是举一个最经常使用的列子:多线程状况下银行帐户存钱和取钱的列子。并发

public class SynchronizedDemo {


    public static void main(String[] args) {
        BankAccount myAccount = new BankAccount("accountOfMG",10000.00);
        for(int i=0;i<100;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        int var = new Random().nextInt(100);
                        Thread.sleep(var);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    double deposit = myAccount.deposit(1000.00);
                    System.out.println(Thread.currentThread().getName()+" balance:"+deposit);
                }
            }).start();
        }
        for(int i=0;i<100;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        int var = new Random().nextInt(100);
                        Thread.sleep(var);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    double deposit = myAccount.withdraw(1000.00);
                    System.out.println(Thread.currentThread().getName()+" balance:"+deposit);

                }
            }).start();
        }
    }

    private static class BankAccount{
        String accountName;
        double balance;

        public BankAccount(String accountName,double balance){
            this.accountName = accountName;
            this.balance = balance;
        }

        public double deposit(double amount){
            balance = balance + amount;
            return balance;
        }

        public double  withdraw(double amount){
            balance = balance - amount;
            return balance;
        }

    }
}

上面的列子中,首先初始化了一个银行帐户,帐户的余额是10000.00,而后开始了200个线程,其中100个每次向帐户中存1000.00,另外100个每次从帐户中取1000.00。若是正常执行的话,帐户中应该仍是10000.00。可是咱们执行屡次这段代码,会发现执行结果基本上都不是10000.00,并且每次结果 都是不同的。框架

出现上面这种结果的缘由就是:在多线程状况下,银行帐户accountOfMG是一个共享变量,对共享变量进行修改若是不作线程同步的话是会存在线程安全问题的。好比说如今有两个线程同时要对帐户accountOfMG存款1000,一个线程先拿到帐户的当前余额,而且将余额加上1000。可是还没将余额的值刷新回帐户,另外一个线程也来作相同的操做。此时帐户余额仍是没加1000以前的值,因此当两个线程执行完毕以后,帐户加的总金额仍是只有1000。dom

synchronized就是Java提供的一种线程同步机制。使用synchronized咱们能够很是方便地解决上面的银行帐户多线程存钱取钱问题,只须要使用synchronized修饰存钱和取钱方法便可:ide

private static class BankAccount{
        String accountName;
        double balance;

        public BankAccount(String accountName,double balance){
            this.accountName = accountName;
            this.balance = balance;
        }
        //这边给出一个编程建议:当咱们对共享变量进行同步时,同步代码块最好在共享变量中加
        public synchronized double deposit(double amount){
            balance = balance + amount;
            return balance;
        }
        
        public synchronized double  withdraw(double amount){
            balance = balance - amount;
            return balance;
        }

    }

2. Java对象头

上面提到,当线程进入synchronized方法或者代码块时须要先获取锁,退出时须要释放锁。那么这个锁信息到底存在哪里呢?

Java对象保存在内存中时,由如下三部分组成:

  • 对象头
  • 实例数据
  • 对齐填充字节

而对象头又由下面几部分组成:

  • Mark Word
  • 指向类的指针
  • 数组长度(只有数组对象才有)

1. Mark Word
Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操做都和Mark Word有关。Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。

Mark Word在不一样的锁状态下存储的内容不一样,在32位JVM中是这么存的:

其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态仍是偏向锁状态。Epoch是指偏向锁的时间戳。

JDK1.6之后的版本在处理同步锁时存在锁升级的概念,JVM对于同步锁的处理是从偏向锁开始的,随着竞争愈来愈激烈,处理方式从偏向锁升级到轻量级锁,最终升级到重量级锁。

JVM通常是这样使用锁和Mark Word的:

  • step1:当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。

  • step2:当对象被当作同步锁并有一个线程A抢到了锁时,锁标志位仍是01,可是否偏向锁那一位改为1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。

  • step3:当线程A再次试图来得到锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A本身的id,表示线程A已经得到了这个偏向锁,能够执行同步锁的代码。

  • step4:当线程B试图得到这个锁时,JVM发现同步锁处于偏向状态,可是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操做试图得到锁,这里的得到锁操做是有可能成功的,由于线程A通常不会自动释放偏向锁。若是抢锁成功,就把Mark Word里的线程id改成线程B的id,表明线程B得到了这个偏向锁,能够执行同步锁代码。若是抢锁失败,则继续执行步骤5。

  • step5:偏向锁状态抢锁失败,表明当前锁有必定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操做都是CAS操做,若是保存成功,表明线程抢到了同步锁,就把Mark Word中的锁标志位改为00,能够执行同步锁代码。若是保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。

  • step6:轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是表明不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。若是抢锁成功则执行同步锁代码,若是失败则继续执行步骤7。

  • step7:自旋锁重试以后若是抢锁依然失败,同步锁会升级至重量级锁,锁标志位改成10。在这个状态下,未抢到锁的线程都会被阻塞。

2. 指向类的指针
该指针在32位JVM中的长度是32bit,在64位JVM中长度是64bit。Java对象的类数据保存在方法区。

3. 数组长度
只有数组对象保存了这部分数据。该数据在32位和64位JVM中长度都是32bit。

synchronized对锁的优化

Java 6中为了减小得到锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”的概念。在Java 6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争状况逐渐升级。锁能够升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。

在聊偏向锁、轻量级锁和重量级锁以前咱们先来聊下锁的宏观分类。锁从宏观上来分类,能够分为悲观锁与乐观锁。注意,这里说的的锁能够是数据库中的锁,也能够是Java等开发语言中的锁技术。悲观锁和乐观锁其实只是一类概念(对某类具体锁的总称),不是某种语言或是某个技术独有的锁技术。

乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,因此不会上锁,可是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采起在写时先读出当前版本号,而后加锁操做(比较跟上一次的版本号,若是同样则更新),若是失败则要重复读-比较-写的操做。java中的乐观锁基本都是经过CAS操做实现的,CAS是一种更新的原子操做,比较当前值跟传入值是否同样,同样则更新,不然失败。数据库中的共享锁也是一种乐观锁。

悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,因此每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中典型的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如ReentrantLock。数据库中的排他锁也是一种悲观锁。

偏向锁

Java 6以前的synchronized会致使争用不到锁的线程进入阻塞状态,线程在阻塞状态和runnbale状态之间切换是很耗费系统资源的,因此说它是java语言中一个重量级的同步操纵,被称为重量级锁。为了缓解上述性能问题,Java 6开始,引入了轻量锁与偏向锁,默认启用了自旋,他们都属于乐观锁

偏向锁更准确的说是锁的一种状态。在这种锁状态下,系统中只有一个线程来争夺这个锁。线程只要简单地经过Mark Word中存放的线程ID和本身的ID是否一致就能拿到锁。下面简单介绍下偏向锁获取和升级的过程。

仍是就着这张图讲吧,会清楚点。

当系统中尚未访问过synchronized代码时,此时锁的状态确定是“无锁状态”,也就是说“是不是偏向锁”的值是0,“锁标志位”的值是01。此时有一个线程1来访问同步代码,发现锁对象的状态是"无锁状态",那么操做起来很是简单了,只须要将“是否偏向锁”标志位改为1,再将线程1的线程ID写入Mark Word便可。

若是后续系统中一直只有线程1来拿锁,那么只要简单的判断下线程1的ID和Mark Word中的线程ID,线程1就能很是轻松地拿到锁。可是现实每每不是那么简单的,如今假设线程2也要来竞争同步锁,咱们看下状况是怎么样的。

  • step1:线程2首先根据“是不是偏向锁”和“锁标志位”的值判断出当前锁的状态是“偏向锁”状态,可是Mark Word中的线程ID又不是指向本身(此时线程ID仍是指向线程1),因此此时回去判断线程1仍是否存在;
  • step2:假如此时线程1已经不存在了,线程2会将Mark Word中的线程ID指向本身的线程ID,锁不升级,仍为偏向锁;
  • step3:假如此时线程1还存在(线程1还没执行完同步代码,【不知道这样理解对不对,姑且先这么理解吧】),首先暂停线程1,设置锁标志位为00,锁升级为“轻量级锁”,继续执行线程1的代码;线程2经过自旋操做来继续得到锁。

在JDK6中,偏向锁是默认启用的。它提升了单线程访问同步资源的性能。但试想一下,若是你的同步资源或代码一直都是多线程访问的,那么消除偏向锁这一步骤对你来讲就是多余的。事实上,消除偏向锁的开销仍是蛮大的。
因此在你很是熟悉本身的代码前提下,大可禁用偏向锁:

-XX:-UseBiasedLocking=false

轻量级锁

"轻量级锁"锁也是一种锁的状态,这种锁状态的特色是:当一个线程来竞争锁失败时,不会当即进入阻塞状态,而是会进行一段时间的锁自旋操做,若是自旋操做拿锁成功就执行同步代码,若是通过一段时间的自旋操做仍是没拿到锁,线程就进入阻塞状态。

1. 轻量级锁加锁流程
线程在执行同步块以前,JVM会先在当前线程的栈桢中建立用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。而后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。若是成功,当前线程得到锁,若是失败,表示其余线程竞争锁,当前线程便尝试使用自旋来获取锁。

2. 轻量级锁解锁流程
轻量级解锁时,会使用原子的CAS操做将Displaced Mark Word替换回到对象头,若是成功,则表示没有竞争发生。若是失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

重量级锁

由于自旋会消耗CPU,为了不无用的自旋(好比得到锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其余线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁以后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

锁自旋

自旋锁原理很是简单,若是持有锁的线程能在很短期内释放锁资源,那么那些等待竞争锁的线程就不须要作内核态和用户态之间的切换进入阻塞挂起状态,它们只须要等一等(自旋),等持有锁的线程释放锁后便可当即获取锁,这样就避免用户线程和内核的切换的消耗。

可是线程自旋是须要消耗CPU的,说白了就是让CPU在作无用功,线程不能一直占用CPU自旋作无用功,因此须要设定一个自旋等待的最大时间。若是持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会致使其它争用锁的线程在最大等待时间内仍是获取不到锁,这时争用线程会中止自旋进入阻塞状态。

自旋锁尽量的减小线程的阻塞,这对于锁的竞争不激烈,且占用锁时间很是短的代码块来讲性能能大幅度的提高,由于自旋的消耗会小于线程阻塞挂起操做的消耗!可是若是锁的竞争激烈,或者持有锁的线程须要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,由于自旋锁在获取锁前一直都是占用cpu作无用功,线程自旋的消耗大于线程阻塞挂起操做的消耗,其它须要cup的线程又不能获取到cpu,形成cpu的浪费。

JDK7以后,锁的自旋特性都是由JVM自身控制的,不须要咱们手动配置。

锁对比

参考

  • https://blog.csdn.net/lkforce/article/details/81128115
  • 《并发编程艺术》
相关文章
相关标签/搜索