1. synchronize 简介
线程运行时拥有自己的栈空间,会在自己的栈空间运行,如果多线程间没有共享的数据也就是说多线程间并没有协作完成一件事情,那么,多线程就不能发挥优势,不能带来巨大的价值。那么共享数据的线程安全问题怎样处理?很自然而然的想法就是每一个线程依次去读写这个共享变量,这样就不会有任何数据安全的问题,因为每个线程所操作的都是当前最新的版本数据。那么,在java关键字 synchronized
就具有使每个线程依次排队操作共享变量的功能。虽然这种同步机制效率很低,但却是其他并发容器实现的基础。
2. synchronize 实现原理
synchronize 可以用在方法和代码块中:
如果锁的是类对象的话,尽管 new 多个实例对象,但他们仍然是属于同一个类依然会被锁住,即线程之间保证同步关系。
注意:上锁不是锁定一段代码,而是锁定某个对象,只有获取某个对象的锁,才能执行这段代码。
2.1 对象锁(monitor)机制
现在我们来看看 synchronized 的具体底层实现。先写一个简单的 demo:
public class SynchronizedDemo {
public static void main(String[] args) {
synchronized (SynchronizedDemo.class) {
}
method();
}
private static void method() {
}
}
上面的代码中有一个同步代码块,锁住的是类对象,并且还有一个同步静态方法,锁住的依然是该类的类对象。编译之后,切换到 SynchronizedDemo.class
的同级目录之后,然后用 javap -v SynchronizedDemo.class
查看字节码文件:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: ldc #2 // class com/xxl/job/admin/zcq/SynchronizedDemo
2: dup
3: astore_1
4: monitorenter //*
5: aload_1
6: monitorexit //*
7: goto 15
10: astore_2
11: aload_1
12: monitorexit //*
13: aload_2
14: athrow
15: invokestatic #3 // Method method:()V
18: return
观察字节码文件中加了 //*
的地方,指定同步代码块要先执行 monitorenter,退出的时候执行 monitroexit(有两个,是因为异常的时候要执行一个)
使用**Synchronized**
进行同步,其关键就是必须要对对象的监视器 **monitor**
进行获取,当线程获取 **monitor**
后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到 **monitor**
。
demo 执行完同步代码块之后,执行一个静态同步方法 method()
,这个方法锁的对象还是类对象。那么这个正在执行的线程还需要获取该锁吗?答案是不必的,从上图中就可以看出来,执行静态同步方法的时候就只有一条 monitorexit
指令,并没有monitorenter
获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。Synchronized 先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。
下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:
该图可以看出,任意线程对 Object 的访问,首先要获得 Object 的监视器,如果获取失败,该线程就进入同步状态,线程状态变为 BLOCKED
,当 Object 的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。
线程去加锁流程大致也就是这样:
原理和思路 大概是这样的,monitor 里面有一个计数器,从 0 开始的。如果一个线程要获取 monitor 的锁,就看看他的计数器是不是 0,如果是 0 的话,那么说明没人获取锁,他就可以获取锁了,然后对计数器加 1。如果不是,就进入 block
阻塞状态,等待锁的获取,即直到 monitor
里面的计数器的值变为 0。
3. synchronize 优化
synchronize 最大的特征就是在同一时刻只有一个线程能够获得对象的监视器(monitor),从而进入到同步代码块或者同步方法之中,即表现为互斥性(排它性 mutex)。这种方式效率很低,每次只能通过一个线程,既然每次只能通过一个,这种形式不能改变的话,那么我们能不能让每次通过的速度变快一点了。
打个比方,去收银台付款,之前的方式是,大家都去排队,然后去纸币付款收银员找零,有的时候付款的时候在包里拿出钱包再去拿出钱,这个过程是比较耗时的,然后,支付宝解放了大家去钱包找钱的过程,现在只需要扫描下就可以完成付款了,也省去了收银员跟你找零的时间的了。同样是需要排队,但整个付款的时间大大缩短,是不是整体的效率变高速率变快了?这种优化方式同样可以引申到锁优化上,缩短获取锁的时间。
synchronized 在JDK 1.6 之前是重量级锁,在J DK1.6 以后对 synchronized 做了优化,增加了偏向锁,轻量级锁,锁粗化,锁消除,适应性自旋等操作,大大增加了 synchronized 的效率。
3.1 CAS 操作
3.1.1 什么是CAS?
使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。而 CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作是使用 CAS(compare and swap) 又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。
3.1.2 CAS 操作过程
CAS 的实现需要硬件指令集的支撑,在 JDK1.5 后虚拟机才可以使用处理器提供的 **CMPXCHG**
指令实现。CAS 在底层的硬件级别给你保证一定是原子的,同一时间只有一个线程可以执行 CAS,先比较再设置,其他的线程的 CAS 同时间去执行此时会失败。
CAS 底层AtomicInteger
→ Unsafe.compareAndSwapInt
→ jdk8u.unsafe.cpp
: cmpxchg = compare and exchange
如果是多 CPU 需要加 lock 指令:lock cmpxchg
指令,否则不需要加 lock
synchronized VS CAS
元老级的 synchronized
(未优化前)最主要的问题是:在存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)。而 CAS 并不是武断的间线程挂起,当 CAS 操作失败后会进行一定的尝试,而非进行耗时的挂起唤醒的操作,因此也叫做非阻塞同步。这是两者主要的区别。
3.1.3 CAS 应用场景
在 J.U.C 包中利用 CAS 实现类有很多,可以说是支撑起整个 concurrency
包的实现,在 Lock 实现中会有 CAS 改变 state 变量,在 atomic 包中的实现类也几乎都是用 CAS 实现。比如:
3.1.4 CAS 的问题
① ABA 问题
- 因为 CAS 会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。
- 解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号或者时间戳可以解决。原来的变化路径
A->B->A
就变成了1A->2B->3A
。在 java 1.5 后的 atomic 包中提供了AtomicStampedReference
来解决 ABA 问题,解决思路就是这样的。**AtomicStampedReference**
主要维护包含一个对象引用以及一个可以自动更新的整数「stamp」的 pair 对象来解决 ABA 问题。
//关键代码
public class AtomicStampedReference<V> {
private static class Pair<T> {
final T reference; //维护对象引用
final int stamp; //用于标志版本
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
private volatile Pair<V> pair;
....
/**
* expectedReference :更新之前的原始值
* newReference : 将要更新的新值
* expectedStamp : 期待更新的标志版本
* newStamp : 将要更新的标志版本
*/
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair; //获取当前pair
return
expectedReference == current.reference && //原始值等于当前pair的值引用,说明值未变化
expectedStamp == current.stamp && // 原始标记版本等于当前pair的标记版本,说明标记未变化
((newReference == current.reference &&
newStamp == current.stamp) || // 将要更新的值和标记都没有变化
casPair(current, Pair.of(newReference, newStamp))); // cas 更新pair
}
}
使用实例:
private static AtomicStampedReference<Integer> atomicStampedRef =new AtomicStampedReference<>(1, 0);
public static void main(String[] args){
Thread main = new Thread(() -> {
System.out.println("操作线程" + Thread.currentThread() +",初始值 a = " + atomicStampedRef.getReference());
int stamp = atomicStampedRef.getStamp(); //获取当前标识别
try {
Thread.sleep(1000); //等待1秒 ,以便让干扰线程执行
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean isCASSuccess = atomicStampedRef.compareAndSet(1,2,stamp,stamp +1); //此时expectedReference未发生改变,但是stamp已经被修改了,所以CAS失败
System.out.println("操作线程" + Thread.currentThread() +",CAS操作结果: " + isCASSuccess);
},"主操作线程");
Thread other = new Thread(() -> {
Thread.yield(); // 确保thread-main 优先执行
atomicStampedRef.compareAndSet(1,2,atomicStampedRef.getStamp(),atomicStampedRef.getStamp() +1);
System.out.println("操作线程" + Thread.currentThread() +",【increment】 ,值 = "+ atomicStampedRef.getReference());
atomicStampedRef.compareAndSet(2,1,atomicStampedRef.getStamp(),atomicStampedRef.getStamp() +1);
System.out.println("操作线程" + Thread.currentThread() +",【decrement】 ,值 = "+ atomicStampedRef.getReference());
},"干扰线程");
main.start();
other.start();
}
输出:
> 操作线程Thread[主操作线程,5,main],初始值 a = 1
> 操作线程Thread[干扰线程,5,main],【increment】 ,值 = 2
> 操作线程Thread[干扰线程,5,main],【decrement】 ,值 = 1
> 操作线程Thread[主操作线程,5,main],CAS操作结果: false
② 自旋时间过长
- 使用 CAS 时非阻塞同步,也就是说不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,如果这里 自旋时间过长对性能是很大的消耗。在高并发时,如果大量线程频繁修改同一个值,可能会导致大量线程执行。
compareAndSet()
方法时需要循环N次才能设置成功,即大量线程执行一个重复的空循环(自旋),造成大量开销。解决无线循环问题可以使用 java8 中的**LongAdder**
,分段 CAS 和自动分段迁移。 LongAddr 原理和使用
- AtomicLong 中有个内部变量 value 保存着实际的 long 值,所有的操作都是针对该变量进行。也就是说,高并发环境下,value 变量其实是一个热点,也就是 N 个线程竞争一个热点。
- LongAdder 的基本思路就是分散热点,将 value 值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行 CAS 操作,这样热点就被分散了,冲突的概率就小很多。如果要获取真正的 long 值,只要将各个槽中的变量值累加返回。(思路类似于 ConcurrentHashMap 在 1.7 版本中的「分段锁」)。它里面维护一组按需分配的计数单元,并发计数时,不同的线程可以在不同的计数单元上进行计数,这样减少了线程竞争,提高了并发效率。本质上是用空间换时间的思想
- 低并发、一般的业务场景下
AtomicLong
是足够了。如果并发量很多,存在大量写多读少的情况,那LongAdder
可能更合适。 LongAddr 的 API
③ 只能保证一个共享变量的原子操作
当对一个共享变量执行操作时 CAS 能保证其原子性,如果对多个共享变量进行操作,CAS 就不能保证其原子性。有一个解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。然后将这个对象做 CAS 操作就可以保证其原子性。atomic 中提供了
**AtomicReference**
来保证引用对象之间的原子性。
3.2 Java 对象头
:::info
锁是给对象的一个标志,此标志存放在 Java 对象的对象头。
:::
在同步的时候是获取对象的 monitor,即获取到对象的锁。那么对象的锁怎么理解?无非就是类似对对象的一个标志,那么这个标志就是存放在 Java 对象的对象头。Java 对象头里的 Mark Word 里默认的存放的对象的 Hashcode,分代年龄和锁标记位。32 位 JVM Mark Word 默认存储结构为(注:java对象头以及下面的锁状态变化摘自《java并发编程的艺术》一书,该书我认为写的足够好,就没在自己组织语言班门弄斧了):
Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。对象的 MarkWord 变化为下图:
MarkWord(8 字节 32 bit) 里面有锁信息
- 锁信息
- GC 信息(分代年龄)
- hashcode
3.3 偏向锁
大多数情况下,锁可能不存在多线程竞争,而是由同一个线程多次获取,为了让锁的获得代价变低,引入了偏向锁。
偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。
轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。
偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此,只需要在 Mark Word 中 CAS 记录 owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于 owner 就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。
3.3.1 偏向锁的获取
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单地测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。
如果测试成功,表示线程已经获得了锁(偏向锁)。
如果测试失败,则需要再测试一下 Mark Word 中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用 CAS 竞争锁(对应第一次偏向锁的 cas 获取);如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程(升级轻量级锁)。
3.3.2 偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
如图,偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的 Mark Word 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
下图线程1展示了偏向锁获取的过程,线程2展示了偏向锁撤销的过程
3.4 轻量级锁
轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。
使用轻量级锁时,不需要申请互斥量,仅仅_将 Mark Word 中的部分字节 CAS 更新指向线程栈中的 **Lock Record**
,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。
3.4.1 加锁
线程在执行同步块之前,JVM 会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中,官方称为 Displaced Mark Word。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
3.4.2 解锁
轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图。
因为自旋会消耗 CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
3.5 自旋锁
首先,内核态与用户态的切换上不容易优化。但通过自旋锁,可以减少线程阻塞造成的线程切换(包括挂起线程和恢复线程)。
如果锁的粒度小,那么锁的持有时间比较短(尽管具体的持有时间无法得知,但可以认为,通常有一部分锁能满足上述性质)。那么,对于竞争这些锁的而言,因为锁阻塞造成线程切换的时间与锁持有的时间相当,减少线程阻塞造成的线程切换,能得到较大的性能提升。具体如下:
- 当前线程竞争锁失败时,打算阻塞自己
- 不直接阻塞自己,而是自旋(空等待,比如一个空的有限 for 循环)一会
- 在自旋的同时重新竞争锁
- 如果自旋结束前获得了锁,那么锁获取成功;否则,自旋结束后阻塞自己
缺点
- 单核处理器上,不存在实际的并行,当前线程不阻塞自己的话,旧owner就不能执行,锁永远不会释放,此时不管自旋多久都是浪费;进而,如果线程多而处理器少,自旋也会造成不少无谓的浪费。
- 自旋锁要占用CPU,如果是计算密集型任务,这一优化通常得不偿失,减少锁的使用是更好的选择。
- 如果锁竞争的时间比较长,那么自旋通常不能获得锁,白白浪费了自旋占用的CPU时间。这通常发生在锁持有时间长,且竞争激烈的场景中,此时应主动禁用自旋锁。
3.5.1 自适应自旋锁
自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:
- 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。
- 相反的,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源。
- 自适应自旋解决的是“锁竞争时间不确定”的问题。JVM 很难感知到确切的锁竞争时间,而交给用户分析就违反了JVM 的设计初衷。自适应自旋假定不同线程持有同一个锁对象的时间基本相当,竞争程度趋于稳定,因此,可以根据上一次自旋的时间与结果调整下一次自旋的时间。
缺点
然而,自适应自旋也没能彻底解决该问题,如果默认的自旋次数设置不合理(过高或过低),那么自适应的过程将很难收敛到合适的值。
3.6 各种锁的比较
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁的消耗 | 适用于只有一个线程访问同步块的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗 CPU | 追求响应时间 同步块执行速度非常快 |
重量级锁 | 线程竞争不是用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较长 |
偏向锁 没有自旋等待的过程(不存在竞争,只有一个线程执行),而轻量级锁会自旋一段时间(存在短暂的竞争)
3.7 锁分配和膨胀过程(Synchronized 原理)
简化版:
3.8. 小结
偏向锁、轻量级锁、重量级锁适用于不同的并发场景:
- 偏向锁:无实际竞争,且将来只有第一个申请锁的线程会使用锁。
- 轻量级锁:无实际竞争,多个线程交替使用锁;允许短时间的锁竞争。
- 重量级锁:有实际竞争,且锁竞争时间长。
另外,如果锁竞争时间短,可以使用自旋锁进一步优化轻量级锁、重量级锁的性能,减少线程切换。如果锁竞争程度逐渐提高(缓慢),那么从偏向锁逐步膨胀到重量锁,能够提高系统的整体性能。
Synchronized 早期重量级锁,因为锁申请必须通过 **kernel**
(内核态)系统调动。后边优化后的偏向锁、自旋锁技术只需要用户态处理
为什么有自旋锁还需要重量级锁?
自旋是消耗 CPU 资源的,如果锁的时间长,或者自旋线程多,CPU 会被大量消耗。重量级锁有等待队列 wait_set
,所有拿不到锁的进入等待队列,不需要消耗 CPU 资源
偏向锁是否一定比自旋锁效率高?
不ー定,在明确知道会有多线程争的情况下,偏向锁肯定会涉及锁撤销,这时候直接使用自旋锁。
比如,JVM 启动过程,会有很多线程竞争(明确),所以认情況启动时不打开偏向锁,过一段时间再打开(启动后 4 秒)