1.设计同步器的意义
临界资源: 多线程中的共享、可变资源。这种资源可能是: 对象、变量、文件等。所以需要采用同步机制来协同对对象可变状态的访问。
共享:资源可以由多个线程同时访问
可变:资源可以在其生命周期内被修改
1.1.如何解决线程并发安全问题?
解决方案: 序列化访问临界资源,即:同一时刻只能有一个线程访问临界资源(同步互斥访问)。
Java 提供的两种方式:synchronized 和 Lock
synchronized : 在JDK < 1.6 以前,性能极低,JDK 1.6以后做了一些列优化,使其效率与ReentrantLock相当,是一种内置锁(隐式锁)。
ReentrantLock: 由于以前 synchronized 性能低下,Doug Lea 使用Java语言编写了基于AQS的 ReentrantLock 可重入锁,它具有可重入和公平性,是一种显示锁。
2.synchronized原理详解
synchronized 内置锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。
加锁的方式:具体可以查看此链接: 线程间的通信与线程安全问题.
1、同步实例方法,锁是当前实例对象
2、同步类方法,锁是当前类对象
3、同步代码块,锁是括号里面的对象
2.1.synchronized底层原理
synchronized 是基于 JVM 内置锁实现,通过内部对象 Monitor(监视器锁) 实现,基于进入与退出 Monitor 对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的 Mutex lock(互斥锁)实现,它是一个重量级锁性能较低。JVM内置锁在JDK 1.5 以后版本做了重大的优化,如使用 锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、
偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning) 等技术来减少锁操作的开销,内置锁的并发性能已经基本与 Lock持平。
synchronized 关键字被编译成字节码后会被翻译成 monitorenter 和 monitorexit 两条指令分别在同步块逻辑代码的起始位置与结束位置。
每个同步对象都有一个自己的Monitor(监视器锁),加锁过程如下图所示:
2.1.1.Monitor(监视器锁)
什么是 monitor ?
Monitor 被翻译为管程或监视器锁。可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。所有的 Java 对象是天生的Monitor,每一个Java对象都有成为 Monitor 的潜质,因为在Java的设计中 ,每一个 Java 对象都自带了一把看不见的锁,它叫做内部锁或者Monitor锁(即Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因)。也就是通常说 synchronized 的对象锁,MarkWord 锁标识位为10,其中指针指向的是 Monitor 对象的起始地址。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于 HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):
Minitor结构:
- 刚开始时 Moinitor 为NULL
- 当 Thread 执行到 synchronized(obj) 就会将 Monitor 的所有者Owner置为Thread-2,Moinitor 只能有一个所有者
- 在 Thread 上锁过程中,如果 Thread-3、Thread-4、Thread-5 也执行到 synchronized(obj),就会进入到 EntryList BLOCKED
- Thread-2 同步代码块执行完毕,就会唤醒 EntryList 中等待线程来竞争锁,竞争时是非公平的
- 图中WaitSet中的Thread-0、Thread-1是之前获得过锁,但条件不满足而放弃锁进入 WAITING 等待状态的线程(在下方解释)
wait/notify原理
- Owner线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
- BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU
- BLOCKED 线程会在 Owener 线程释放锁时被唤醒
- WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后进入 EntryLis t重新竞争
注意:
synchronized 必须是进入同一个对象的 Monitor 才会有以上效果
不加 sychronized 对象不会关联 Monitor,不尊遵从以上规则
字节码角度理解synchronized加锁原理:
static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
对应字节码为:
方法的同步没有 monitorenter 和 monitorexit 指令,但相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的: 当方法调用时,调用指令将会检查方法是否有 ACC_SYNCHRONIZED 标识符,如果有,执行线程将先获取 monitor 。
public class SynchronizedMethod {
public synchronized void method() {
System.out.println("Hello World!");
}
}
反编译结果:
两个指令的执行是 JVM 通过调用操作系统的 互斥原语 mutex 来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
3.对象内存布局
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data) 和对齐填充 (Padding)。
实例数据: 存放类的属性数据信息,包括父类的属性信息;
对齐填充: 虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐;
对象头: Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节(32bit),在64位虚拟机中,1个机器码是8个字节(64bit)),但是如果对象是数组类型,则需要3个机器码,因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
注意:图中每一行都代表 32bit
下面是 32 为虚拟机的对象头: 64位的对象头浪费空间,JVM默认会开启指针压缩,所以基本上也是按 32 位的形式记录对象头的,什么是指针压缩参考链接: JVM-03.对象创建与内存分配.
对象头分析工具
运行时对象头锁状态分析工具JOL,OpenJDK开源工具包,引入下方 maven 依赖,通过对象头分析,我们可以具体的分析出当前锁对象的状态。可以分析锁升级膨胀的过程、HashCode、线程ID等信息。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol‐core</artifactId>
<version>0.10</version>
</dependency>
打印markword
// object是锁对象
System.out.println(ClassLayout.parseInstance(object).toPrintable());
注意:操作系统又分为大小端,我们常用的 Linux 和 Windows 都是小端模式,它们的 markword 打印出来会地址颠倒。如(0000001 00000000 00000000 00000000 就是 0000000 00000000 00000000 00000001)
4.synchronized锁的升级膨胀过程
JDK1.6 以前,都是重量级锁。JDK6 时逐步对 sychronized进行了一系列的优化,在某些场景甚至比CAS更快。
锁膨胀升级过程(简易):
4.1.偏向锁
在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些 CAS 操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时, 无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
注意: JVM 会延迟启动偏向锁(4秒),因为 JVM 内部也有部分线程带有 synchronized,延迟启动可以避免无谓的升级为轻量级锁。
4.2.轻量级锁
轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
4.3.自旋锁
轻量级锁失败后,JVM 不会立即将其升级为重量级锁,在大多数情况下,线程持有锁的时间都不会太长,所以 JVM 会让等待锁的线程自旋(会让当前想要获取锁的线程做几个空循环)等待锁释放。如果自旋一定次数仍然未等待到锁释放,就会升级为重量级锁。
JDK1.6后自旋所锁自适应的,比如说对象刚刚的一次自旋操作成功过,JVM 会认为自旋成功的可能性就高一些,从而多自旋几次。反之,就少自旋甚至不自旋
重量级锁竞争的时候,也可以使用自旋来进行优化,如果当前线程自旋成功,当前线程就可以避免阻塞。
自旋会占用CPU时间,单核CPU自旋就是浪费,只有多核CPU自旋才能优化
4.4.重量级锁
通过操作系统 Metux 元语实现,性能极差。竞争激烈情况下才会出现。
4.5.锁粗化
如果虚拟机探测到有一串零碎操作都是对同一对象加锁,将会把加锁同步的范围扩展到整个操作序列的外部。
例如: 多次循环进入同步块不如同步块内多次循环
// StringBuffer是线程安全的,内部被synchronized修饰
//JVM可能会优化把多次append的加锁操作粗化为一次
//因为都是对用一个对象加锁,没有必要重入多次
new StringBuffer().appen("a").appen("b").appen("c");
4.6.锁消除
Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。锁消除的依据是逃逸分析的数据支持。
例如:
// 优化前
synchronized (obj) {
逻辑1
}
synchronized (obj) {
逻辑2
}
//锁消除后
synchronized (obj) {
逻辑1
逻辑2
}
逃逸分析
使用逃逸分析,编译器可以对代码做如下优化:
一、同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
二、将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配(通过标量替换,将对象(聚合量)转化为标量(对象里面的成员))的候选,而不是堆分配。
三、分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存, 而是存储在CPU寄存器中。
4.7.其他优化
- 减少上锁时间
同步代码块尽量短 - 减少锁的粒度
将一个锁拆分为多个锁,提高并发度。例如:
A.ConcurrentHashMap
B.LongAdder(累加工具类)分为 base 和 cell 两部分。没有并发竞争的时候或者cells数组正在初始化的时候,会使用 CAS 来累加值到 base,有并发争用是,会初始化cells数组,数组有多个cell,就运行有多少线程并行修改,最后将数组中每个cell累加,在加上base就是最终值。
C.LinkedBlockingQueue(基于链表的阻塞队列)入队和出队使用不同的锁,相对于 LinkedBlockingArray 只有一个锁效率要高 - 读写分离
读读的是原始数据内容,写会复制一份,在一个新数组上写,读操作不需要同步,仅需要写加锁
CopyOnWriteArrayList
CopyOnWriteSet