synchronized是什么有什么用?
synchronized是在多线程场景经常用到的关键字,通过synchronized将共享资源设置为临界资源,确保并发场景下共享资源操作的正确性。
synchronized基础使用示例
synchronized作用于静态方法
synchronized作用于静态方法上,锁的对象为Class,这就意味着方法的调用者无论是Class还是实例对象都可以保持互斥,所以下面这段代码的结果为200
public class SynchronizedDemo { private static Logger logger = LoggerFactory.getLogger(SynchronizedDemo.class); private static int count = 0; /** * synchronized作用域静态类上 */ public synchronized static void method() { count++; } @Test public void test() { IntStream.rangeClosed(1,1_0000) .parallel() .forEach(i->SynchronizedDemo.method()); IntStream.rangeClosed(1,1_0000) .parallel() .forEach(i->new SynchronizedDemo().method()); logger.info("count:{}",count); } }
输出结果
22:59:44.647 [main] INFO com.sharkChili.webTemplate.SynchronizedDemo - count:20000
synchronized作用于方法
作用于方法上,则锁住的对象是调用的示例对象,如果我们使用下面这段写法,最终的结果却不是10000。
private static Logger logger = LoggerFactory.getLogger(SynchronizedDemo.class); private static int count = 0; /** * synchronized作用域实例方法上 */ public synchronized void method() { count++; } @Test public void test() { IntStream.rangeClosed(1,1_0000) .parallel() .forEach(i->new SynchronizedDemo().method()); logger.info("count:{}",count); } }
输出结果
2023-03-16 21:03:44,300 INFO SynchronizedDemo:30 - count:8786
因为synchronized 作用于实例方法,会导致每个线程获得的锁都是各自使用的实例对象,而++操作又非原子操作,导致互斥失败进而导致数据错误。
什么是原子操作呢?通俗的来说就是一件事情只要一条指令就能完成,而count++在底层汇编指令如下所示,可以看到++操作实际上是需要3个步骤完成的:
- 从内存将count读取到寄存器
- count自增
- 写回内存
__asm { moveax, dword ptr[i] inc eax mov dwordptr[i], eax }
正是由于锁互斥的失败,导致两个线程同时到临界区域加载资源,获得的count都是0,经过自增后都是1,导致数据少了1。
所以正确的使用方式是多个线程使用同一个对象调用该方法
SynchronizedDemo demo = new SynchronizedDemo(); IntStream.rangeClosed(1,1_0000) .parallel() .forEach(i->demo.method()); logger.info("count:{}",count);
这样一来输出的结果就正常了。
2023-03-16 23:08:23,656 INFO SynchronizedDemo:31 - count:10000
synchronized作用于代码块
作用于代码块上的synchronized锁住的就是括号内的对象实例,以下面这段代码为例,锁的就是当前调用者。
public void method() { synchronized (this) { count++; } }
所以我们的使用的方式还是和作用与实例方法上一样。
@Test public void test() { SynchronizedDemo demo = new SynchronizedDemo(); IntStream.rangeClosed(1, 1_0000) .parallel() .forEach(i -> demo.method()); logger.info("count:{}", count); }
输出结果也是10000
2023-03-16 23:11:08,496 INFO SynchronizedDemo:33 - count:10000
synchronized实现原理
我们先来写一段简单的Java程序,synchronized作用于代码块中
public class SynchronizedDemo { private static int count = 0; /** * synchronized作用域实例方法上 */ public void method() { synchronized (this) { count++; } } public static void main(String[] args) { SynchronizedDemo demo = new SynchronizedDemo(); IntStream.rangeClosed(1, 1_0000) .parallel() .forEach(i -> demo.method()); System.out.println("count:" + count); } }
先使用javac指令生成class文件
javac SynchronizedDemo.java
然后再使用反编译javap
javap -c -s -v SynchronizedDemo.class
最终我们可以看到method方法的字节码指令,可以看到关键字synchronized 的锁是通过monitorenter和monitorexit来确保线程间的同步。
public void method(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=3, args_size=1 0: aload_0 1: dup 2: astore_1 3: monitorenter 4: getstatic #2 // Field count:I 7: iconst_1 8: iadd 9: putstatic #2 // Field count:I 12: aload_1 13: monitorexit 14: goto 22 17: astore_2 18: aload_1 19: monitorexit 20: aload_2 21: athrow 22: return
我们再将synchronized 关键字改到方法上再次进行编译和反编译
public synchronized void method() { count++; }
可以看到synchronized 实现锁的方式编程了通过ACC_SYNCHRONIZED关键字来标明该方法是一个同步方法。
public synchronized void method(); descriptor: ()V flags: ACC_PUBLIC, ACC_SYNCHRONIZED Code: stack=2, locals=1, args_size=1 0: getstatic #2 // Field count:I 3: iconst_1 4: iadd 5: putstatic #2 // Field count:I 8: return LineNumberTable: line 17: 0 line 19: 8
了解了不同synchronized在不同位置使用的指令之后,我们再来聊聊这些指令如何实现"锁"的。
我们每个线程使用的实例对象都有一个对象头,每个对象头中都有一个Mark Word,当我们使用synchronized 关键字时,这个Mark Word就会指向一个monitor。
这个monitor锁就是一种同步工具,是实现线程操作临界资源互斥的关键所在,在Java虚拟机(HotSpot)中,monitor就是通过ObjectMonitor实现的。
其代码如下,我们可以看到_EntryList、_WaitSet 、_owner三个关键属性。
ObjectMonitor() { _header = NULL; _count = 0; // 记录线程获取锁的次数 _waiters = 0, _recursions = 0; //锁的重入次数 _object = NULL; _owner = NULL; // 指向持有ObjectMonitor对象的线程 _WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet _WaitSetLock = 0 ; _Responsible = NULL ; _succ = NULL ; _cxq = NULL ; FreeNext = NULL ; _EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表 _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; }
我们假设自己现在就是一个需要获取锁的线程,要获取ObjectMonitor锁,所以我们经过了下面几个步骤:
- 进入_EntryList。
- 尝试取锁,发现_owner区被其他线程持有,于是进入_WaitSet 。
- 其他线程用完锁,将count–变为0,释放锁,_owner被清空。
- 我们有机会获取_owner,尝试争抢,成功获取锁,_owner指向我们这个线程,将count++。
- 我们操作到一半发现CPU时间片用完了,调用wait方法,线程再次进入_WaitSet ,count–变为0,_owner被清空。
- 我们又有机会获取_owner,尝试争抢,成功获取锁,将count++。
- 这一次,我们用完临界资源,准备释放锁,count–变为0,_owner清空,其他线程继续进行monitor争抢。
synchronized如何保证可见性、有序性、可重入性
可见性
每个线程使用synchronized获得锁操作临界资源时,首先需要获取临界资源的值,为了保证临界资源的值是最新的,JMM模型规定线程必须将本地工作内存清空,到共享内存中加载最新的进行操作。
当前线程上锁后,其他线程是无法操作这个临界资源的。
当前线程操作完临界资源之后,会立刻将值写回内存中,正是由于每个线程操作期间其他线程无法干扰,且临界资源数据实时同步,所以synchronized关键字保证了临界资源数据的可见性。
有序性
synchronized同步的代码块具备排他性,这就意味着同一个时刻只有一个线程可以获得锁,synchronized代码块的内部资源是单线程执行的。
synchronized遵守as-if-serial原则,可以当线程线程修改最终结果是有序的,注意这里笔者说的保证最终结果的有序性。
具体例子,某段线程得到锁Test.class之后,执行临界代码逻辑,可能会先执行变量b初始化的逻辑,在执行a变量初始化的逻辑,但是最终结果都会执行a+b的逻辑。这也就我们的说的保证最终结果的有序,而不保证执行过程中的指令有序。
synchronized (Test.class) { int a=1; int b=2; int c=a+b; }
可重入性
Java允许同一个线程获取同一把锁两次,即可重入性,原因我们上文将synchronized相关的ObjectMonitor锁已经提到了,ObjectMonitor有一个count变量就是用于记录当前线程获取这把锁的次数。
就像下面这段代码,例如我们的线程T1,两次执行synchronized 获取锁Test.class两次,count就自增两次变为2。
退出synchronized关键字对应的代码块,count就自减,变为0时就代表释放了这把锁,其他线程就可以争抢这把锁了。所以当我们的线程退出下面的两个synchronized 代码块时,其他线程就可以争抢Test.class这把锁了。
public void add2() { synchronized (Test.class) { synchronized (Test.class){ list.add(1); } } }
synchronized锁粗化和锁消除
锁粗化
当jvm发现操作的方法连续对同一把锁进行加锁、解锁操作,就会对锁进行粗化,所有操作都在同一把锁中完成。
如下代码,StringBuffer的append方法有synchronized关键字,这意味每次追加操作都会上对象锁,代码中涉及连续3个字符串的追加,jvm发现这一点就会对其进行优化,将3次append操作合并,用一次锁定完成。
/** * 锁粗化 * @param s1 * @param s2 * @param s3 * @return */ public static String test04(String s1, String s2, String s3) { StringBuffer sb = new StringBuffer(); //锁粗化后下面3个append会在同一个锁中执行 sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); }
锁粗化后的效果,大概像下面这段代码:
public static String test04(String s1, String s2, String s3) { StringBuilder sb = new StringBuilder(); synchronized(sb){ sb.append(s1); sb.append(s2); sb.append(s3); } return sb.toString(); }
锁消除
虚拟机在JIT即时编译运行时,对一些代码上要求同步,但是检测到不存在共享数据的锁的进行消除。
下面这段代码涉及字符串拼接操作,所以jvm会将其优化为StringBuffer或者StringBuilder,至于选哪个,这就需要进行逃逸分析了。逃逸分析通俗来说就是判断当前操作的对象是否会逃逸出去被其他线程访问到。
关于逃逸分析可以可以参考笔者的这篇文章来聊聊逃逸分析
例如我们下面的result ,是局部变量,没有发生逃逸,所以完全可以当作栈上数据来对待,是线程安全的,所以jvm进行锁消除,使用StringBuilder完成字符串拼接。
public String appendStr(String str1, String str2, String str3) { String result = str1 + str2 + str3; return result; }
这一点我们可以在字节码文件中得到印证
synchronized的锁升级
原理详解
synchronized关键字在JDK1.6之前底层都是直接调用ObjectMonitor的enter和exit完成对操作系统级别的重量级锁mutex的使用,这使得每次上锁都需要从用户态转内核态尝试获取重量级锁的过程。
这种方式也不是不妥当,在并发度较高的场景下,取不到mutex的线程会因此直接阻塞,到等待队列_WaitSet 中等待唤醒,而不是原地自选等待其他线程释放锁而立刻去争抢,从而避免没必要的线程原地自选等待导致的CPU开销,这也就是我们上文中讲到的synchronized工作原理的过程。
但是在并发度较低的场景下,可能就10个线程,竞争并不激烈可能线程等那么几毫秒就可以拿到锁了,而我们每个线程却还是需要不断从用户态到内核态获取重量级锁、到_WaitSet 中等待机会的过程,这种情况下,可能功能的开销还不如所竞争的开销来得激烈。
所以JDK1.6之后,HotSpot虚拟机就对synchronized底层做了一定的优化,通俗来说根据线程竞争的激烈程度的不断增加逐步进行锁升级的策略。
我们假设有这样一个场景,我们有一个锁对象LockObj,我们希望用它作为锁,使用代码逻辑如下所示:
synchronized(LockObj){ //dosomething }
我们把自己当作一个线程,一开始没有线程竞争时,synchronized锁就是无锁状态,无需进行任何锁争抢的逻辑。此时锁对象LockObj的偏向锁标志位为0,锁标记为01。
随着时间推移有几个线程开始竞争,竞争并不激烈的时候,就将锁升级为偏向锁,此时作为锁的对象LockObj的对象头偏向锁标记为1,锁标记为01,我们的线程开始尝试获取这把锁,如果获得这把锁或者发现持有这把锁的线程id就是我们自己,则直接操作临界资源即可。当我们发现偏向锁中指向的线程id不是我们时,就执行下面的逻辑:
- 我们尝试CAS竞争这把锁,如果成功则将锁对象的markdown中的线程id设置为我们的线程id,然后执行代码逻辑。
- 我们尝试CAS竞争这把锁失败,则当持有锁的线程到达安全点的时候,直接将这个线程挂起,将偏向锁升级为轻量级锁,然后持有锁的线程继续自己的逻辑,我们的线程继续等待机会。
这里可能有读者好奇什么叫安全点?
这里我们可以通俗的理解一下,安全点就是代码执行到的一个特殊位置,当线程执行到这个位置时,我们可以将线程暂停下来,让我们在暂停期间做一些处理。我们上文中将偏向锁升级为轻量级锁就是在安全点将线程暂停一下,将锁升级为轻量级锁,然后再让线程进行进一步的工作。
关于安全点的更多介绍,可以参考这篇文章
升级为轻量级锁时,偏向锁标记为0,锁标记变为是00。此时,如果我们的线程需要获取这个轻量级锁时的过程如下:
- 判断当前这把锁是否为轻量级锁,如果是则在线程栈帧中划出一块空间,存放这把锁的信息,我们这里就把它称为"锁记录",并将锁对象的markword复制到锁记录中。
- 复制成功之后,通过CAS的方式尝试将锁对象头中markword更新为锁记录的地址,并将owner指向锁对象头的markword。如果这几个步骤操作成功,则说明取锁成功了。
- 如果失败,jvm则会去查看锁对象中的markword是否指向我们的锁空间,如果是我们的线程则代表锁重入,则我们的线程可以操作临界资源。如果不是我们的线程,则说明这把锁被别的线程持有了,我们再次进行原地自旋等待,如果自旋超过10次(默认设置为10次)还没有得到锁则将锁升级为重量级锁。
升级为重量级锁时,锁标记为0,锁状态为10。
小结
经过上述的讲解我们对锁升级有了一个全流程的认识,在这里做个阶段小结:
- 无线程竞争,无锁状态:偏向锁标记为0,锁标记为01。
- 存在一定线程竞争,大部分情况下会是同一个线程获取到,升级为偏向锁,偏向标记为1,锁标记为01。
- 线程CAS争抢偏向锁锁失败,锁升级为轻量级锁,偏向标记为0,锁标记为00。
- 线程原地自旋超过10次还未取得轻量级锁,锁升级为重量级锁,避免大量线程原地自旋造成没必要的CPU开销,偏向锁标记为0,锁标记为10。
代码印证
上文我们将自己当作一个线程了解完一次锁升级的流程,口说无凭,所以我们通过可以通过代码来印证我们的描述。
上文讲解锁升级的之后,我们一直在说对象头的概念,所以为了能够直观的看到锁对象中对象头锁标记和锁状态的变化,我们这里引入一个jol工具。
<!--jol内存分析工具--> <dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.9</version> </dependency>
然后我们声明一下锁对象作为实验对象。
public class Lock { private int count; public int getCount() { return count; } public void setCount(int count) { this.count = count; } }
首先是无锁状态的代码示例,很简单,没有任何线程争抢逻辑,就通过jol工具打印锁对象信息即可。
public class Lockless { public static void main(String[] args) { Lock object=new Lock(); System.out.println(ClassLayout.parseInstance(object).toPrintable()); } }
打印结果如下,我们只需关注第一行的object header,可以看到第一列的00000001,我们看到后3位为001,偏向锁标记为0,锁标记为01,001这就是我们说的无锁状态。
com.zsy.lock.lockUpgrade.Lock object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387) 12 4 int Lock.count 0 Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
接下来是偏向锁,我们还是用同样的代码即可,需要注意的是偏向锁必须在jvm启动后的一段时间才会运行,所以如果我们想打印偏向锁必须让线程休眠那么几秒,这里笔者就偷懒了一下,通过设置jvm参数-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
,通过禁止偏向锁延迟,直接打印出偏向锁信息
public class BiasLock { public static void main(String[] args) { Lock object = new Lock(); System.out.println(ClassLayout.parseInstance(object).toPrintable()); } }
输出结果如下,可以看到对象头的信息为00000101,此时锁标记为1即偏向锁标记,锁标记为01,101即偏向锁。
com.zsy.lock.lockUpgrade.Lock object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387) 12 4 int Lock.count 0 Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
然后的轻量级锁的印证,我们只需使用Lock对象作为锁即可。
public class LightweightLock { public static void main(String[] args) { Lock object = new Lock(); synchronized (object) { System.out.println(ClassLayout.parseInstance(object).toPrintable()); } } }
可以看到轻量级锁锁标记为0,锁标记为00,000即轻量级。
com.zsy.lock.lockUpgrade.Lock object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) e8 f1 96 02 (11101000 11110001 10010110 00000010) (43446760) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387) 12 4 int Lock.count 0 Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
最后就是重量级锁了,我们只需打印出锁对象的哈希码即可将其升级为重量级锁。
public class HeavyweightLock { public static void main(String[] args) { Lock object = new Lock(); synchronized (object) { System.out.println(object.hashCode()); } synchronized (object) { System.out.println(ClassLayout.parseInstance(object).toPrintable()); } } }
输出结果为10001010,偏向锁标记为0,锁标记为10,010为重量级锁。
1365202186 com.zsy.lock.lockUpgrade.Lock object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 8a 15 83 17 (10001010 00010101 10000011 00010111) (394466698) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387) 12 4 int Lock.count 0 Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
补充jol-core包的其他妙用
jol不仅仅可以监控Java进程的锁情况,在某些场景下,我们希望通过比较对象的地址来判断当前创建的实例是否是多例,是否存在线程安全问题。此时,我们就可以VM对象的方法获取对象地址,如下所示:
public static void main(String[] args) throws Exception { //打印字符串aa的地址 System.out.println(VM.current().addressOf("aa")); }
聊聊Java关键字synchronized(下)
https://developer.aliyun.com/article/1493791?spm=a2c6h.13148508.setting.25.1e9b4f0eQyphEf