5

13张图,深入理解Synchronized - InfoQ 写作平台

 3 years ago
source link: https://xie.infoq.cn/article/f8979b16f83b413fefb07ba9f
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Java 并发编程系列第二篇Synchronized,文章风格依然是图文并茂,通俗易懂,本文带读者们由浅入深理解Synchronized,让读者们也能与面试官疯狂对线。

在并发编程中Synchronized一直都是元老级的角色,Jdk 1.6以前大家都称呼它为重量级锁,相对于J U C包提供的Lock,它会显得笨重,不过随着Jdk 1.6Synchronized进行各种优化后,Synchronized性能已经非常快了。

Synchronized 使用方式

SynchronizedJava提供的同步关键字,在多线程场景下,对共享资源代码段进行读写操作(必须包含写操作,光读不会有线程安全问题,因为读操作天然具备线程安全特性),可能会出现线程安全问题,我们可以使用Synchronized锁定共享资源代码段,达到互斥mutualexclusion)效果,保证线程安全。

共享资源代码段又称为临界区critical section),保证临界区互斥,是指执行临界区critical section)的只能有一个线程执行,其他线程阻塞等待,达到排队效果。

Synchronized的食用方式有三种

  • 修饰普通函数,监视器锁(monitor)便是对象实例(this

  • 修饰静态静态函数,视器锁(monitor)便是对象的Class实例(每个对象只有一个Class实例)

  • 修饰代码块,监视器锁(monitor)是指定对象实例

普通函数使用Synchronized的方式很简单,在访问权限修饰符函数返回类型间加上Synchronized

多线程场景下,threadthreadTwo两个线程执行incr函数,incr函数作为共享资源代码段被多线程读写操作,我们将它称为临界区,为了保证临界区互斥,使用Synchronized修饰incr函数即可。

public class SyncTest {    private int j = 0;    /**     * 自增方法     */    public synchronized void incr(){        //临界区代码--start        for (int i = 0; i < 10000; i++) {            j++;        }        //临界区代码--end    }    public int getJ() {        return j;    }}public class SyncMain {    public static void main(String[] agrs) throws InterruptedException {        SyncTest syncTest = new SyncTest();        Thread thread = new Thread(() -> syncTest.incr());        Thread threadTwo = new Thread(() -> syncTest.incr());        thread.start();        threadTwo.start();        thread.join();        threadTwo.join();        //最终打印结果是20000,如果不使用synchronized修饰,就会导致线程安全问题,输出不确定结果        System.out.println(syncTest.getJ());    }}

代码十分简单,incr函数被synchronized修饰,函数逻辑是对j进行10000次累加,两个线程执行incr函数,最后输出j结果。

synchronized修饰函数我们简称同步函数,线程执行称同步函数前,需要先获取监视器锁,简称锁,获取锁成功才能执行同步函数同步函数执行完后,线程会释放锁并通知唤醒其他线程获取锁,获取锁失败「则阻塞并等待通知唤醒该线程重新获取锁」,同步函数会以this作为锁,即当前对象,以上面的代码段为例就是syncTest对象。

  • 线程thread执行syncTest.incr()

  • 线程thread获取锁成功

  • 线程threadTwo执行syncTest.incr()

  • 线程threadTwo获取锁失败

  • 线程threadTwo阻塞并等待唤醒

  • 线程thread执行完syncTest.incr()j累积到10000

  • 线程thread释放锁,通知唤醒threadTwo线程获取锁

  • 线程threadTwo获取锁成功

  • 线程threadTwo执行完syncTest.incr()j累积到20000

  • 线程threadTwo释放锁

静态函数顾名思义,就是静态的函数,它使用Synchronized的方式与普通函数一致,唯一的区别是锁的对象不再是this,而是Class对象。

多线程执行Synchronized修饰静态函数代码段如下。

public class SyncTest {    private static int j = 0;    /**     * 自增方法     */    public static synchronized void incr(){        //临界区代码--start        for (int i = 0; i < 10000; i++) {            j++;        }        //临界区代码--end    }    public static int getJ() {        return j;    }}public class SyncMain {    public static void main(String[] agrs) throws InterruptedException {        Thread thread = new Thread(() -> SyncTest.incr());        Thread threadTwo = new Thread(() -> SyncTest.incr());        thread.start();        threadTwo.start();        thread.join();        threadTwo.join();        //最终打印结果是20000,如果不使用synchronized修饰,就会导致线程安全问题,输出不确定结果        System.out.println(SyncTest.getJ());    }}

Java的静态资源可以直接通过类名调用,静态资源不属于任何实例对象,它只属于Class对象,每个ClassJ V M中只有唯一的一个Class对象,所以同步静态函数会以Class对象作为锁,后续获取锁、释放锁流程都一致。

前面介绍的普通函数与静态函数粒度都比较大,以整个函数为范围锁定,现在想把范围缩小、灵活配置,就需要使用代码块了,使用{}符号定义范围给Synchronized修饰。

下面代码中定义了syncDbData函数,syncDbData是一个伪同步数据的函数,耗时2秒,并且逻辑不涉及共享资源读写操作非临界区),另外还有两个函数incrincrTwo,都是在自增逻辑前执行了syncDbData函数,只是使用Synchronized的姿势不同,一个是修饰在函数上,另一个是修饰在代码块上。

public class SyncTest {    private static int j = 0;    /**     * 同步库数据,比较耗时,代码资源不涉及共享资源读写操作。     */    public void syncDbData() {        System.out.println("db数据开始同步------------");        try {            //同步时间需要2秒            Thread.sleep(2000);        } catch (InterruptedException e) {            e.printStackTrace();        }        System.out.println("db数据开始同步完成------------");    }    //自增方法    public synchronized void incr() {        //start--临界区代码        //同步库数据        syncDbData();        for (int i = 0; i < 10000; i++) {            j++;        }        //end--临界区代码    }    //自增方法    public void incrTwo() {        //同步库数据        syncDbData();        synchronized (this) {            //start--临界区代码            for (int i = 0; i < 10000; i++) {                j++;            }            //end--临界区代码        }    }    public int getJ() {        return j;    }}public class SyncMain {    public static void main(String[] agrs) throws InterruptedException {        //incr同步方法执行        SyncTest syncTest = new SyncTest();        Thread thread = new Thread(() -> syncTest.incr());        Thread threadTwo = new Thread(() -> syncTest.incr());        thread.start();        threadTwo.start();        thread.join();        threadTwo.join();        //最终打印结果是20000        System.out.println(syncTest.getJ());        //incrTwo同步块执行        thread = new Thread(() -> syncTest.incrTwo());        threadTwo = new Thread(() -> syncTest.incrTwo());        thread.start();        threadTwo.start();        thread.join();        threadTwo.join();        //最终打印结果是40000        System.out.println(syncTest.getJ());    }}

先看看incr同步方法执行,流程和前面没区别,只是Synchronized锁定的范围太大,把syncDbData()也纳入临界区中,多线程场景执行,会有性能上的浪费,因为syncDbData()完全可以让多线程并行并发执行。

我们通过代码块的方式,来缩小范围,定义正确的临界区,提升性能,目光转到incrTwo同步块执行,incrTwo函数使用修饰代码块的方式同步,只对自增代码段进行锁定。

代码块同步方式除了灵活控制范围外,还能做线程间的协同工作,因为Synchronized ()括号中能接收任何对象作为锁,所以可以通过Objectwait、notify、notifyAll等函数,做多线程间的通信协同(本文不对线程通信协同做展开,主角是Synchronized,而且也不推荐去用这些方法,因为LockSupport工具类会是更好的选择)。

  • wait:当前线程暂停,释放锁

  • notify:释放锁,唤醒调用了 wait 的线程(如果有多个随机唤醒一个)

  • notifyAll:释放锁,唤醒调用了 wait 的所有线程

Synchronized 原理

  public class SyncTest {    private static int j = 0;    /**     * 同步库数据,比较耗时,代码资源不涉及共享资源读写操作。     */    public void syncDbData() {        System.out.println("db数据开始同步------------");        try {            //同步时间需要2秒            Thread.sleep(2000);        } catch (InterruptedException e) {            e.printStackTrace();        }        System.out.println("db数据开始同步完成------------");    }    //自增方法    public synchronized void incr() {        //start--临界区代码        //同步库数据        syncDbData();        for (int i = 0; i < 10000; i++) {            j++;        }        //end--临界区代码    }    //自增方法    public void incrTwo() {        //同步库数据        syncDbData();        synchronized (this) {            //start--临界区代码            for (int i = 0; i < 10000; i++) {                j++;            }            //end--临界区代码        }    }    public int getJ() {        return j;    }} 

为了探究Synchronized原理,我们对上面的代码进行反编译,输出反编译后结果,看看底层是如何实现的(环境 Java 11、win 10 系统)。

  只截取了incr与incrTwo函数内容  public synchronized void incr();    Code:       0: aload_0                                                1: invokevirtual #11                 // Method syncDbData:()V        4: iconst_0                                 5: istore_1                                 6: iload_1                                            7: sipush        10000                     10: if_icmpge     27      13: getstatic     #12                 // Field j:I      16: iconst_1      17: iadd      18: putstatic     #12                 // Field j:I      21: iinc          1, 1      24: goto          6      27: return  public void incrTwo();        Code:       0: aload_0       1: invokevirtual #11                 // Method syncDbData:()V       4: aload_0       5: dup       6: astore_1       7: monitorenter                     //获取锁       8: iconst_0       9: istore_2      10: iload_2      11: sipush        10000      14: if_icmpge     31      17: getstatic     #12                 // Field j:I      20: iconst_1      21: iadd      22: putstatic     #12                 // Field j:I      25: iinc          2, 1      28: goto          10      31: aload_1      32: monitorexit                      //正常退出释放锁       33: goto          41      36: astore_3      37: aload_1      38: monitorexit                      //异步退出释放锁          39: aload_3      40: athrow      41: return

ps:对上面指令感兴趣的读者,可以百度或 google 一下“JVM 虚拟机字节码指令表”

先看incrTwo函数,incrTwo是代码块方式同步,在反编译后的结果中,我们发现存在monitorentermonitorexit指令(获取锁、释放锁)。

monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,J V M需要保证每一个 monitorenter都有monitorexit与之对应。

任何对象都有一个监视器锁(monitor)关联,线程执行monitorenter指令时尝试获取monitor的所有权。

  • 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程为monitor的所有者

  • 如果线程已经占有该monitor,重新进入,则monitor的进入数加1

  • 线程执行monitorexitmonitor的进入数-1,执行过多少次monitorenter,最终要执行对应次数的monitorexit

  • 如果其他线程已经占用monitor,则该线程进入阻塞状态,直到monitor的进入数为 0,再重新尝试获取monitor的所有权

回过头看incr函数,incr是普通函数方式同步,虽然在反编译后的结果中没有看到monitorentermonitorexit指令,但是实际执行的流程与incrTwo函数一样,通过monitor来执行,只不过它是一种隐式的方式来实现,最后放一张流程图。

Synchronized 优化

Jdk 1.5以后对Synchronized关键字做了各种的优化,经过优化后Synchronized已经变得越来越快了,这也是为什么官方建议使用Synchronized的原因,具体的优化点如下。

  • 锁粗化

  • 锁消除

  • 锁升级

互斥的临界区范围应该尽可能小,这样做的目的是为了使同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

但是加锁解锁也需要消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,锁粗化就是将「多个连续的加锁、解锁操作连接在一起」,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。

J V M会检测到一连串的操作都对同一个对象加锁(for循环10000次执行j++,没有锁粗化就要进行10000次加锁/解锁),此时J V M就会将加锁的范围粗化到这一连串操作的外部(比如for循环体外),使得这一连串操作只需要加一次锁即可。

Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析(对象在函数中被使用,也可能被外部函数所引用,称为函数逃逸),去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的时间消耗。

代码中使用Object作为锁,但是Object对象的生命周期只在incrFour()函数中,并不会被其他线程所访问到,所以在J I T编译阶段就会被优化掉(此处的Object属于没有逃逸的对象)。

Java中每个对象都拥有对象头,对象头由Mark World 、指向类的指针、以及数组长度三部分组成,本文,我们只需要关心Mark World 即可, Mark World 记录了对象的HashCode、分代年龄和锁标志位信息。

Mark World 简化结构

读者们只需知道,锁的升级变化,体现在锁对象的对象头Mark World部分,也就是说Mark World的内容会随着锁升级而改变。

Java1.5以后为了减少获取锁和释放锁带来的性能消耗,引入了偏向锁轻量级锁Synchronized的升级顺序是 「无锁-->偏向锁-->轻量级锁-->重量级锁,只会升级不会降级

在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁,其目标就是在只有一个线程执行同步代码块时,降低获取锁带来的消耗,提高性能(可以通过 J V M 参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态)。

线程执行同步代码或方法前,线程只需要判断对象头的Mark Word中线程ID与当前线程ID是否一致,如果一致直接执行同步代码或方法,具体流程如下

  • 无锁状态,存储内容「是否为偏向锁(0)」,锁标识位01

  • CAS设置当前线程 ID 到Mark Word存储内容中

  • 是否为偏向锁0 => 是否为偏向锁1

  • 执行同步代码或方法

  • 偏向锁状态,存储内容「是否为偏向锁(1)、线程 ID」,锁标识位01

  • 对比线程ID是否一致,如果一致执行同步代码或方法,否则进入下面的流程

  • 如果不一致,CASMark Word的线程ID设置为当前线程ID,设置成功,执行同步代码或方法,否则进入下面的流程

  • CAS设置失败,证明存在多线程竞争情况,触发撤销偏向锁,当到达全局安全点,偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后在安全点的位置恢复继续往下执行。

轻量级锁考虑的是竞争锁对象的线程不多,持有锁时间也不长的场景。因为阻塞线程需要C P U从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失,所以干脆不阻塞这个线程,让它自旋一段时间等待锁释放。

当前线程持有的锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。轻量级锁的获取主要有两种情况:① 当关闭偏向锁功能时;② 多个线程竞争偏向锁导致偏向锁升级为轻量级锁。

  • 无锁状态,存储内容「是否为偏向锁(0)」,锁标识位01

  • 关闭偏向锁功能时

  • CAS设置当前线程栈中锁记录的指针到Mark Word存储内容

  • 锁标识位设置为00

  • 执行同步代码或方法

  • 释放锁时,还原来Mark Word内容

  • 轻量级锁状态,存储内容「线程栈中锁记录的指针」,锁标识位00(存储内容的线程是指"持有轻量级锁的线程")

  • CAS设置当前线程栈中锁记录的指针到Mark Word存储内容,设置成功获取轻量级锁,执行同步块代码或方法,否则执行下面的逻辑

  • 设置失败,证明多线程存在一定竞争,线程自旋上一步的操作,自旋一定次数后还是失败,轻量级锁升级为重量级锁

  • Mark Word存储内容替换成重量级锁指针,锁标记位10

轻量级锁膨胀之后,就升级为重量级锁,重量级锁是依赖操作系统的MutexLock互斥锁)来实现的,需要从用户态转到内核态,这个成本非常高,这就是为什么Java1.6之前Synchronized效率低的原因。

升级为重量级锁时,锁标志位的状态值变为10,此时Mark Word中存储内容的是重量级锁的指针,等待锁的线程都会进入阻塞状态,下面是简化版的锁升级过程。

历史好文推荐

这里是阿星,一个热爱技术的 Java 程序猿,公众号 「程序猿阿星」里将会定期分享操作系统、计算机网络、Java、分布式、数据库等精品原创文章,2021,与您在 Be Better 的路上共同成长!。

非常感谢各位小哥哥小姐姐们能看到这里,原创不易,文章有帮助可以关注、点个赞、分享与评论,都是支持(莫要白嫖)!

愿你我都能奔赴在各自想去的路上,我们下篇文章见


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK