文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

13张图,深入理解Synchronized

2024-12-03 04:36

关注

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

内容大纲

Synchronized使用方式

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

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

Synchronized的食用方式有三种

普通函数

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

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

  1. public class SyncTest {  
  2.     private int j = 0;    
  3.       
  4.     public synchronized void incr(){  
  5.         //临界区代码--start  
  6.         for (int i = 0; i < 10000; i++) {  
  7.             j++;  
  8.         }  
  9.         //临界区代码--end  
  10.     }  
  11.     public int getJ() {  
  12.         return j;  
  13.     }  
  14. public class SyncMain {  
  15.     public static void main(String[] agrs) throws InterruptedException {  
  16.         SyncTest syncTest = new SyncTest();  
  17.         Thread thread = new Thread(() -> syncTest.incr());  
  18.         Thread threadTwo = new Thread(() -> syncTest.incr());  
  19.         thread.start();  
  20.         threadTwo.start();  
  21.         thread.join();  
  22.         threadTwo.join();  
  23.         //最终打印结果是20000,如果不使用synchronized修饰,就会导致线程安全问题,输出不确定结果  
  24.         System.out.println(syncTest.getJ());  
  25.     }  

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

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

静态函数

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

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

  1. public class SyncTest {  
  2.     private static int j = 0;      
  3.       
  4.     public static synchronized void incr(){  
  5.         //临界区代码--start  
  6.         for (int i = 0; i < 10000; i++) {  
  7.             j++;  
  8.         }  
  9.         //临界区代码--end  
  10.     }  
  11.     public static int getJ() {  
  12.         return j;  
  13.     }  
  14.  
  15. public class SyncMain {  
  16.     public static void main(String[] agrs) throws InterruptedException {  
  17.         Thread thread = new Thread(() -> SyncTest.incr());  
  18.         Thread threadTwo = new Thread(() -> SyncTest.incr());  
  19.         thread.start();  
  20.         threadTwo.start();  
  21.         thread.join();  
  22.         threadTwo.join();  
  23.         //最终打印结果是20000,如果不使用synchronized修饰,就会导致线程安全问题,输出不确定结果  
  24.         System.out.println(SyncTest.getJ());  
  25.     }  

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

代码块

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

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

  1. public class SyncTest {  
  2.     private static int j = 0 
  3.       
  4.     public void syncDbData() {  
  5.         System.out.println("db数据开始同步------------");  
  6.         try {  
  7.             //同步时间需要2秒  
  8.             Thread.sleep(2000);  
  9.         } catch (InterruptedException e) {  
  10.             e.printStackTrace();  
  11.         }  
  12.         System.out.println("db数据开始同步完成------------");  
  13.     }  
  14.     //自增方法  
  15.     public synchronized void incr() {  
  16.         //start--临界区代码  
  17.         //同步库数据  
  18.         syncDbData();  
  19.         for (int i = 0; i < 10000; i++) {  
  20.             j++;  
  21.         }  
  22.         //end--临界区代码  
  23.     }  
  24.     //自增方法  
  25.     public void incrTwo() {  
  26.         //同步库数据  
  27.         syncDbData();  
  28.         synchronized (this) {  
  29.             //start--临界区代码  
  30.             for (int i = 0; i < 10000; i++) {  
  31.                 j++;  
  32.             }  
  33.             //end--临界区代码  
  34.         }  
  35.     }  
  36.     public int getJ() {  
  37.         return j;  
  38.     }  
  39.  
  40. public class SyncMain {  
  41.     public static void main(String[] agrs) throws InterruptedException {  
  42.         //incr同步方法执行  
  43.         SyncTest syncTest = new SyncTest();  
  44.         Thread thread = new Thread(() -> syncTest.incr());  
  45.         Thread threadTwo = new Thread(() -> syncTest.incr());  
  46.         thread.start();  
  47.         threadTwo.start();  
  48.         thread.join();  
  49.         threadTwo.join();  
  50.         //最终打印结果是20000  
  51.         System.out.println(syncTest.getJ());  
  52.         //incrTwo同步块执行  
  53.         thread = new Thread(() -> syncTest.incrTwo());  
  54.         threadTwo = new Thread(() -> syncTest.incrTwo());  
  55.         thread.start();  
  56.         threadTwo.start();  
  57.         thread.join();  
  58.         threadTwo.join();  
  59.         //最终打印结果是40000  
  60.         System.out.println(syncTest.getJ());  
  61.     }  

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

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

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

Synchronized原理 

  1. public class SyncTest {  
  2.     private static int j = 0 
  3.       
  4.     public void syncDbData() {  
  5.         System.out.println("db数据开始同步------------");  
  6.         try {  
  7.             //同步时间需要2秒  
  8.             Thread.sleep(2000);  
  9.         } catch (InterruptedException e) {  
  10.             e.printStackTrace();  
  11.         }  
  12.         System.out.println("db数据开始同步完成------------");  
  13.     }  
  14.     //自增方法  
  15.     public synchronized void incr() {  
  16.         //start--临界区代码  
  17.         //同步库数据  
  18.         syncDbData();  
  19.         for (int i = 0; i < 10000; i++) {  
  20.             j++;  
  21.         }  
  22.         //end--临界区代码  
  23.     }  
  24.     //自增方法  
  25.     public void incrTwo() {  
  26.         //同步库数据  
  27.         syncDbData();  
  28.         synchronized (this) {  
  29.             //start--临界区代码  
  30.             for (int i = 0; i < 10000; i++) {  
  31.                 j++;  
  32.             }  
  33.             //end--临界区代码  
  34.         }  
  35.     }  
  36.     public int getJ() {  
  37.         return j;  
  38.     }  
  39. }  

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

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

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

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

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

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

回过头看incr函数,incr是普通函数方式同步,虽然在反编译后的结果中没有看到monitorenter与monitorexit指令,但是实际执行的流程与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简化结构

锁状态 存储内容 锁标记
无锁 对象的hashCode、对象分代年龄、是否是偏向锁(0) 01
偏向锁 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) 01
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向互斥量(重量级锁)的指针 10

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

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

偏向锁

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

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

轻量级锁

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

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

重量级锁

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

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

 

 

来源:Java编程内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯