线程安全
当多个线程访问同一个类(对象或者静态成员)时,这个类表现出的行为和单个线程访问它时保持一致(在单线程环境执行正确),就称这个类是线程安全的。
无状态的对象一定是线程安全的。
线程安全的诱因
一是存在共享数据(也称临界资源)。
二是存在多条线程共同操作共享数据。
竞态条件
当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。
最常见的竞态条件为:先检查后执行。
例如,小明先检查磁盘上存不存在文件A,检查之后,其实这个检查结果已经失效了,假设A存在,这时有另一个程序在磁盘上删除A,破坏了小明之前的检查结果,现在小明想删除A,却发现报了一个错误,文件不存在。这就是静态条件导致的问题。
原子操作
要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中获取一个未知的状态。
假定有两个操作A和B,在A看来,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。
锁机制
synchronized块(同步代码块)
同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。
每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。
线程在进入同步代码块之前会自动获得锁,无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出,在退出同步代码块时自动释放锁。
同步代码块的锁是互斥锁,同一时刻只能一个线程持有同一把锁。
public void safe(){
synchronized (this){
//do something
}
}
synchronized也可以修饰方法从而使整个方法变成同步方法,如果是实例方法默认的锁是当前对象,静态方法的默认锁则是Class对象。
注意:实例方法加synchronized修饰线程安全的前提是多个线程操作同一个实例。
如果一个类的多个实例方法都加了synchronized修饰,那么这些方法都是互斥的,同一时间只允许一个线程操作同一个实例的其中的一个synchronized方法。
public synchronized void safe(){
//do something
}
另外,同步代码块的锁是可重入的,所以一个线程试图获取一个已经被它持有的锁时,也会成功。
具体实现:
为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。
如果锁是不可重入的,那么递归调用同步方法将会死锁。
挖掘synchronized的原理
ObjectMonitor
每个对象的对象头都持有一个monitor对象的引用,monitor就可以认为是synchronized的实现,monitor在Hotspot虚拟机中实现为ObjectMonitor(称为管程)。
现在从字节码层面看看synchronized的真身 ,先来看以下代码
package com.example.demo.juc;
/**
* <p>同步代码块测试</p>
*
* @author lch
* @version 1.0
* @date 2022/10/23 17:34
*/
public class SyncTest {
private int count=0;
public synchronized void safe(){
count++;
}
public void safe1(){
synchronized (this){
count++;
}
}
public static void main(String[] args) {
}
}
下面为上面的代码编译后的使用javap命令生成的字节码分析文件。
Classfile /E:/javacodetemplate/demo/src/test/java/com/example/demo/juc/SyncTest.class
Last modified 2022-10-25; size 554 bytes
MD5 checksum 7ed2d545194fc32ac5791ae88e8ddc8e
Compiled from "SyncTest.java"
public class com.example.demo.juc.SyncTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#21 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#22 // com/example/demo/juc/SyncTest.count:I
#3 = Class #23 // com/example/demo/juc/SyncTest
#4 = Class #24 // java/lang/Object
#5 = Utf8 count
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 safe
#12 = Utf8 safe1
#13 = Utf8 StackMapTable
#14 = Class #23 // com/example/demo/juc/SyncTest
#15 = Class #24 // java/lang/Object
#16 = Class #25 // java/lang/Throwable
#17 = Utf8 main
#18 = Utf8 ([Ljava/lang/String;)V
#19 = Utf8 SourceFile
#20 = Utf8 SyncTest.java
#21 = NameAndType #7:#8 // "<init>":()V
#22 = NameAndType #5:#6 // count:I
#23 = Utf8 com/example/demo/juc/SyncTest
#24 = Utf8 java/lang/Object
#25 = Utf8 java/lang/Throwable
{
public com.example.demo.juc.SyncTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_0
6: putfield #2 // Field count:I
9: return
LineNumberTable:
line 10: 0
line 11: 4
public synchronized void safe();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED // $$1
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field count:I
5: iconst_1
6: iadd
7: putfield #2 // Field count:I
10: return
LineNumberTable:
line 14: 0
line 15: 10
public void safe1();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter // $$2
4: aload_0
5: dup
6: getfield #2 // Field count:I
9: iconst_1
10: iadd
11: putfield #2 // Field count:I
14: aload_1
15: monitorexit
16: goto 24
19: astore_2
20: aload_1
21: monitorexit //$$3
22: aload_2
23: athrow
24: return
Exception table:
from to target type
4 16 19 any
19 22 19 any
LineNumberTable:
line 17: 0
line 18: 4
line 19: 14
line 20: 24
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 19
locals = [ class com/example/demo/juc/SyncTest, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 24: 0
}
SourceFile: "SyncTest.java"
可以看到safe方法的flags标志有ACC_SYNCHRONIZED这个标志($$1处),这表示此方法是一个同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先持有monitor(管程),然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。
safe1方法的字节码指令中含有monitorenter和monitorexit两个指令($$2处),这两个指令就是synchronized的真身。
monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取对象锁所对应的monitor的持有权,当 对象锁的monitor的进入计数器为0,那线程可以成功取得monitor,并将计数器值设置为1,获得锁的持有权。如果当前线程已经拥有对象锁的monitor的持有权,那它可以重入这个 monitor,并且重入时计数器的值加1。假设monitor已被其他线程拥有,那当前线程将被阻塞,直到正在执行线程的monitorexit指令被执行,执行线程将释放monitor并设置计数器值为0 ,其他线程将有机会持有monitor。
编译器会确保无论方法通无论怎样结束,方法中调用过的每条monitorenter指令都有执行其对应monitorexit指令。编译器会自动产生一个异常处理器($$3处),这个异常处理器声明可处理所有的异常,保证在方法异常完成时monitorexit指令可以正确执行。
JVM对锁的优化
在JDK早期版本,synchronized属于重量级锁,因为monitor依赖于操作系统底层的互斥锁Mutex Lock,操作系统实现线程间的切换需要从用户态切换为内核态,耗费时间较长,所以synchronized早期效率低下。
JDK6之后引入了轻量级锁和偏向锁。
锁有四种状态:无锁、偏向锁、轻量级锁、重量级锁。锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,但是不能降级。
偏向锁
它是一种针对加锁操作的优化手段,一般来说在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,当这个线程再次请求锁时,无需再做任何同步操作,直接获取,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
对于没有锁竞争的场合,偏向锁有很好的优化效果。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样的场景极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁。偏向锁失败后,先升级为轻量级锁。
可以这样理解:要进入一个公司,首先要在大门申请临时卡,标识已经登记过一次,短时间内我再来,就不需要在申请临时卡了,直接进入。
轻量级锁
倘若偏向锁失败,会尝试使用一种称为轻量级锁的优化手段。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
例如:2个人在一张纸上画画,A画完一笔,B再画一笔,两个人交叉作画,不会冲突。
自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。如果还无法获得锁就只能升级为重量级锁了。
比如现在想上厕所,但是厕所有人,先在厕所外原地踏步,别闲着,等几分钟之后如果厕所里的人还没出来,就去睡觉(挂起),如果直接去睡觉,可能厕所里的人很快就出来了,这时候再去唤醒你,就浪费了大量的时间。
锁消除
消除锁是虚拟机另外一种锁的优化,JVM在JIT编译时(即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,消除没有必要的锁,可以节省请求锁的时间。
常见的例子就算在方法中new了一个线程安全的对象来使用,这时JVM会优化掉线程安全的对象方法,也就是说就算在不需要同步的代码里加了锁,也能优化掉。