文章目录
- 一、synchronized
- 1.1 线程安全的解决方案维度
- 1.2 对象 JVM 存储
- 1.2.1 对象头
- 1.3 synchronized
- 1.4 synchronized 底层原理
- 1.4.1 同步方法
- 1.4.2 同步代码块
- 1.5 synchronized 重入的实现机理
- 1.6 synchronized 重量级原因
- 1.7 synchronized 锁优化
- 二、AQS
- 2.1 AQS 概述
- 2.2 AQS 加锁分析
- 2.2.1 lock
- 2.2.2 acquire
- 2.2.3 acquireQueued
- 2.3 AQS 解锁分析
- 2.3.1 unlock
- 三、ReentrantLock
- 3.1 ReentrantLock
- 3.2 ReentrantLock 优点
- 3.3 ReentrantLock 可重入性
- 3.4 ReenTrantLock synchronized 的区别
- 3.5 ReenTrantLock 独有能力
- 四、volatile
- 4.1 Java 内存模型
- 4.2 JMM 关于同步的规定
- 4.3 JMM 对 Java 语义的两个扩展
- 4.4 原子性、有序性、可见性
- 4.4.1 原子性
- 4.4.2 可见性
- 4.4.3 有序性
- 4.5 内存屏障
- 4.6 指令重排序
- 4.7 volatile 概述
- 4.8 volatile 实现机制
- 4.9 volatile 和 Atomic 的区别
- 4.10 volatile 和 synchronized 的区别
- 五、CAS
- 5.1 系统原语
- 5.2 CAS 概述
- 5.3 Unsafe 类
- 5.4 Unsafe 类的内存操作
- 5.5 Unsafe 类的 CAS 操作
- 5.6 CAS 操作的缺点
- 5.7 CAS中ABA问题
一、synchronized
1.1 线程安全的解决方案维度
- 使用同步代码或者同步锁。
- 使用线程安全类,比如 StringBuffer 和 JUC 包下的安全类。
- 数据不共享单线程可见,比如 ThreadLocal 单线程可见的。
1.2 对象 JVM 存储
- 在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。
- 对象头:要包括两部分数据 Mark Word(标记字段)、Class Pointer(类型指针)。
- 实例数据:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例还包括数组的长度,这部分内存按4字节对齐。
- 对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。
1.2.1 对象头
如果是非数组的对象头是8个字节(32位JVM)或者16字节(64位JVM),数组对象还会有一个数组长度(4个字节),因为虚拟机可以通过对象的元数据信息确定对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
- Mark Word默认存储对象的HashCode,分代年龄和锁标志位信息,这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
- Class Pointer是对象指向它的类元数据的指针,虚拟机可以通过这个指针来确定这个对象是哪个类的实例。
1.3 synchronized
synchronized 关键字在使用的时候,需要指定一个对象与之关联,例如 synchronized(this)或者 synchronized(ANOTHER_LOCK),如果修饰的是实例方法,那么其关联的对象实际上是 this,如果修饰的是类方法,那么其关联的对象是 this.class。总之 synchronzied 需要关联一个对象,而这个对象就是 monitor object。
1.4 synchronized 底层原理
synchronized 的同步是基于进入和退出 Monitor 对象来实现的,Monitor 是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的线程同步。
1.4.1 同步方法
对于同步方法,编译器会自动在方法标识上加上 ACC_SYNCHRONIZED 来表示这个方法为同步方法,JVM 根据该标示符来实现方法的同步。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了则执行线程将先获取锁对象(monitor ),获取成功之后才能执行方法体,方法执行完后再释放锁对象(monitor)。
1.4.2 同步代码块
对于同步代码块,编译器会在自动在同步代码块前后加上 monitorenter 和 monitorexit 指令,monitorenter 指令指向同步代码块的开始位置相当于加锁,monitorexit 指令则指明同步代码块的结束位置相当于解锁。
- 每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
- 当执行 monitorenter 时,如果目标锁对象的计数器为 0,那么说明它没有被其他线程所持有,JVM 会将该锁对象的持有线程设置为当前线程,并且将其计数器加1。
- 当目标锁对象的计数器不为 0 时,如果锁对象的持有线程是当前线程,那么 JVM 将其锁计数器加1,否则需要等待知道持有线程释放锁。
- 当执行 monitorexit 时,JVM 将锁对象的计数器减1,当计数器为0时代表锁被释放。
1.5 synchronized 重入的实现机理
每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。当执行 monitorenter 时,如果目标锁对象的计数器为0,那么说明它没有被其他线程所持有,Java 虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1。
当目标锁对象的计数器不为0的情况下,如果锁对象的持有线程是当前线程,那么Java虚拟机将其锁计数器加1,否则需要等待知道持有线程释放锁。
当执行 monitorexit 时,Java虚拟机将锁对象的计数器减1,计数器为0时代表锁被释放。
1.6 synchronized 重量级原因
在 Java 早期版本中 synchronized 属于重量级锁,因为监视器锁(Monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,而操作系统实现线程之间的切换需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
1.7 synchronized 锁优化
- 检测锁对象头的标记字段(Mark Word)里面是不是当前的线程ID,如果是则表示当前线程处于偏向锁。
- 如果不是则使用 CAS 操作将标记字段替换为当前线程的ID,如果成功则表示当前线程获得偏向锁,并设置偏向标志位 1。
- 如果替换失败则说明发生竞争,撤销偏向锁进而升级为轻量级锁。
- 升级为轻量级锁后,当前线程使用 CAS 操作将对标记字段替换为锁记录指针,如果成功则当前线程获得锁。
- 如果替换失败则表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
- 如果自旋成功则依然处于轻量级状态,如果自旋失败,则升级为重量级锁。
二、AQS
2.1 AQS 概述
AQS (AbstractQueuedSynchronizer类) 是一个用来构建锁和同步器的框架,包含 ReentrantLock、Semaphore、CountDownLatch等。
AQS 在内部定义了一个被 volatile 修饰的 int 型变量 state 表示同步状态,当线程调用 lock 方法时,如果 state=0 则说明没有线程占用共享资源的锁,当前线程获取锁并将 state 设置为1,如果 state=1 则说明有线程占用共享资源的锁,其他线程进入同步队列等待。
AQS 通过内部类 Node 构建双向列表的同步队列,用来完成线程获取锁的排队工作。
2.2 AQS 加锁分析
2.2.1 lock
修改变量 state 的值,如果成功则将锁的所有者设置为当前线程,如果失败则执行 acquire 方法,compareAndSetState 方法使用的是 Unsafe 类的 compareAndSwapInt 方法实现的。
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
2.2.2 acquire
acquire 方法:以独占模式获取锁,至少调用一次 tryAcquire 方法,成功则返回,否则线程可能会排队。
- tryAcquire():尝试获取锁,如果成功则直接返回。
- addWaiter():将当前线程添加到队列尾部,并标记为独占模式。
- acquireQueued():使线程阻塞在等待队列中,直到获取到资源后才返回,如果在整个等待过程中被中断过返回true,否则返回false
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
2.2.3 acquireQueued
- 找到当前线程节点的前一个节点,并将其线程状态标记 SIGNAL
- 自旋后调用 LockSupport.park(this) 方法进入 waiting 状态,等待 unpark() 或 interrupt() 唤醒自己
- 被唤醒后尝试获取锁,如果成功获取锁则将 head 指向当前线程节点,并返回中断状态变量 interrupted
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
2.3 AQS 解锁分析
2.3.1 unlock
- 调用 release 方法释放锁
- tryRelease():尝试释放锁,将 state 的值减 1,当 state 的值为 0 时,设置当前线程的持有者为 null
- unparkSuccessor():改变哨兵节点的线程状态,即修改 waitStatus 的值,获取线程队列中第一个线程节点并唤醒 LockSupport.unpark(s.thread);
public void unlock() {
sync.release(1);
}
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
private void unparkSuccessor(AbstractQueuedSynchronizer.Node node) {
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
AbstractQueuedSynchronizer.Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (AbstractQueuedSynchronizer.Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
LockSupport.unpark(s.thread);
}
三、ReentrantLock
3.1 ReentrantLock
ReentrantLock可以实现公平锁和非公平锁,ReentrantLock里面有一个内部类Sync,Sync继承AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync中实现的,它有公平锁FairSync和非公平锁NonfairSync两个子类,ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors(),该方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个,如果是则返回true,否则返回false。
3.2 ReentrantLock 优点
- ReentrantLock 具备非阻塞方式获取锁的特性,使用 tryLock() 方法实现。
- ReentrantLock 可以在指定时间范围内获取锁,使用 tryLock(long timeout,TimeUnit unit) 方法。
- ReentrantLock 可以中断获得的锁,使用 lockInterruptibly() 方法当获取锁之后,如果所在的线程被中断,则会抛出异常并释放当前获得的锁。
3.3 ReentrantLock 可重入性
ReentrantLock 内部自定义了同步器 Sync,该同步器实现了 AQS,而 AQS 提供了一种互斥锁的持有方式,本质就是在加锁的时候通过 CAS 算法,将线程对象放到一个双向链表中,每次获取锁时查看维护的线程 exclusiveOwnerThread 和当前线程是否一致,一致则可以直接进入。
protected final boolean tryAcquire(int acquires) {
....
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
....
}
3.4 ReenTrantLock synchronized 的区别
- 锁的实现:synchronized 依赖于JVM实现,而 ReenTrantLock 是JDK实现。
- 锁的范围:synchronized 可用于修饰方法、代码块,ReentrantLock 只适用于代码块锁。
- 锁的功能:synchronized 不需要手动释放和开启锁,ReentrantLock 使用起来比较灵活,但是必须有释放锁的配合动作。
- 锁的性能:synchronized 优化后,两者的性能就差不多,都是试图在用户态就把加锁问题解决,避免进入内核态的线程阻塞。
3.5 ReenTrantLock 独有能力
- ReenTrantLock 可以指定是公平锁还是非公平锁。而 synchronized 只能是非公平锁。
- ReenTrantLock 提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。
- ReenTrantLock 提供了一个 Condition 类,用来实现分组唤醒需要唤醒的线程,而不是像 synchronized 要么随机唤醒一个线程要么唤醒全部线程。
四、volatile
4.1 Java 内存模型
JVM定义一种Java内存模型JMM(Java Memory Model),来屏蔽掉各种硬件和操作系统的内存访问差异,让Java程序在各种平台上都能达到一致的内存访问效果。JMM本身是一种抽象的概念并不真实存在,描述的是一组规则或规范,通过这组规范定义了程序中各个变量的访问方式。
在JMM中所有的变量都在主内存中(普通内存),每个线程都包含自己的工作内存(CPU寄存器或高速缓存),当线程操作时,首先从主内存中读取变量的值,再加载到自己的工作内存的副本中,然后传递给处理器执行操作,执行完毕后再给工作内存中的副本赋值,随后工作内存将副本的值同步到主内存中更新。
使用工作内存和主内存模式会加快执行速度,但是会带来线程安全问题,JMM主要就是围绕着如何在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的,通过解决这三个问题,可以解除缓存不一致的问题。
4.2 JMM 关于同步的规定
- 线程加锁前,必须读取主内存的最新值到自己的工作内存。
- 线程解锁前,必须把共享变量的值刷新回主内存。
- 加锁和解锁操作必须使用同一把锁。
4.3 JMM 对 Java 语义的两个扩展
- 对volatile语义的扩展保证了变量在一些情况下不会重排序,volatile的64位变量double和long的读取和赋值操作都是原子的。
- 对final语义的扩展保证一个对象的构建方法结束前,所有final成员变量都必须完成初始化(前提是没有this引用溢出)。
4.4 原子性、有序性、可见性
4.4.1 原子性
原子性是指相应的操作是单一不可分割的操作,要么全执行要么全不执行,JMM只实现了基本的原子性。例如在Java中对基本数据类型的读取和赋值操作是原子性操作: i = 2 是原子性操作,但 j = i 不是原子性操作。
4.4.2 可见性
可见性指的是多个线程同时读取共享变量,当其中任意一个线程修改了共享变量的值时,其他线程都可以及时得到最新值。
Java可以通过volatile来提供可见性,当一个变量被volatile修饰时,对该变量的所有修改会立刻刷新到主内存中,当其它线程获取该变量时,会在主内存中读取到最新的值,通过synchronized和Lock也能够保证内存的可见性,在线程释放锁之前,会将变量刷回到主内存中,但是后者的开销会更大。
4.4.3 有序性
JMM允许编译器和处理器对指令重排序的,但是规定了as-if-serial语义,即不管怎么重排序,程序的执行结果不能改变。
指令重排序是编译器和处理器为了高效对程序进行优化的手段,它只能保证程序执行的结果是正确的,无法保证程序的操作顺序与代码顺序一致。这在单线程中不会构成问题,但是在多线程中就会出现问题。
4.5 内存屏障
内存屏障(Memory Barrier)又叫内存栅栏,是一组CPU指令,用于控制特定条件下的重排序和内存可见性问题,硬件层的内存屏障分为两种:读屏障(Load Barrier )和写屏障(Store Barrier)。
内存屏障有两个作用:阻止屏障两侧的指令重排序。强制把缓冲区/高速缓存中的数据等写回主内存,让缓存中相应的数据失效。
4.6 指令重排序
在执行程序时为了提升性能,处理器和编译器常常会对指令进行重排序。
- 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
- 指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
4.7 volatile 概述
volatile是Java虚拟机提供的轻量级同步机制,volatile修饰变量具有以下特点:
- 保证变量操作的内存可见性,当一个线程修改了这个变量的值,修改的新值对于其他线程是可见的。
- 禁止指令重排序优化,保证程序代码的执行顺序,volatile的经典应用:单例模式的DCL。
4.8 volatile 实现机制
- volatile的底层是通过内存屏障来实现的,使用volatile时会增加一条lock前缀指令(汇编指令),这个指令就相当于一个内存屏障。
- 如果某个线程对volatile修饰的共享变量进行更新,那么其他线程可以立马看到这个更新,这就是所谓的线程可见性。
- 内存屏障的另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。
具体表现:
- 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值立即刷新到主内存中。
- 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存无效化直接从主内存中读取共享变量。
4.9 volatile 和 Atomic 的区别
- volatile变量可以保证可见性和有序性,但是不能保证原子性,例如 i++。
- AtomicInteger类提供的atomic方法可以让操作具有原子性,例如getAndIncrement()方法会原子性的进行增量操作,底层是由CAS+自旋锁的方式保证操作的原子性。
4.10 volatile 和 synchronized 的区别
- volatile 不会造成线程的阻塞,synchronized 可能会造成线程的阻塞和上下文切换。
- volatile 仅能使用在变量级别,synchronized 则可以使用在变量、方法、和类级别的。
- volatile 标记的变量不会被编译器优化,synchronized 标记的变量可以被编译器优化。
- volatile 仅能保证变量修改的可见性,synchronized 则可以保证变量的修改可见性和原子性。
- volatile 本质是在告诉jvm当前变量在工作内存中的值是不确定的,需要从主存中读取,synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
五、CAS
5.1 系统原语
系统原语是属于系统用语的范畴,是由若干条指令组成,用于完成某个功能的一个过程。原语的执行是连续的,在执行过程中不允许被中断,是一条CPU的原子指令,不会造成数据不一致的问题。
5.2 CAS 概述
CAS(Compare-And-Swap)比较并交换:是一条CPU并发原语,用于实现多线程同步的CPU原子指令,它将内存位置的值与给定值进行比较,只有在相同的情况下,才会将该内存位置的值修改为新的给定值,这是作为单个原子操作完成的。原子性保证新值基于最新信息计算,如果该值在同一时间被另一个线程更新,则写入将失败。
CAS并发原语体现在Java语言中就是sum.misc.Unsafe类的各个方法,这是一种完全依赖硬件的功能,通过它实现了原子操作。CAS是通过调用JNI的代码实现的,比如在Windows系统中CAS就是借助C语言来调用CPU底层指令实现的。
5.3 Unsafe 类
CAS并发原语体现在Java语言中就是sum.misc.Unsafe类的各个方法。Java不能直接访问操作系统底层,而是通过本地方法来访问,Unsafe类提供了硬件级别的原子操作,提供一下功能:通过Unsafe类可以分配内存和释放内存、线程的挂起与恢复、定位对象某字段的内存位置、修改对象的字段值。
5.4 Unsafe 类的内存操作
这部分主要包含堆外内存的分配、拷贝、释放、给定地址值操作等方法
//分配内存, 相当于C++的malloc函数
public native long allocateMemory(long bytes);
//扩充内存
public native long reallocateMemory(long address, long bytes);
//释放内存
public native void freeMemory(long address);
//在给定的内存块中设置值
public native void setMemory(Object o, long offset, long bytes, byte value);
//内存拷贝
public native void copyMemory(Object srcBase, long srcOffset, Object destBase, long destOffset, long bytes);
//获取给定地址值,忽略修饰限定符的访问限制。与此类似操作还有: getInt,getDouble,getLong,getChar等
public native Object getObject(Object o, long offset);
//为给定地址设置值,忽略修饰限定符的访问限制,与此类似操作还有: putInt,putDouble,putLong,putChar等
public native void putObject(Object o, long offset, Object x);
//获取给定地址的byte类型的值(当且仅当该内存地址为allocateMemory分配时,此方法结果为确定的)
public native byte getByte(long address);
//为给定地址设置byte类型的值(当且仅当该内存地址为allocateMemory分配时,此方法结果才是确定的)
public native void putByte(long address, byte x);
5.5 Unsafe 类的 CAS 操作
CAS操作有3个操作数:内存值V、预期值A、更新值B,当且仅当预期值A和内存值V相同时,则将内存值V修改为B,否则就什么也不做。
/**
* CAS
* @param o 包含要修改field的对象
* @param offset 对象中某field的偏移量
* @param expected 期望值
* @param update 更新值
* @return true | false
*/
public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update);
5.6 CAS 操作的缺点
- CAS存在ABA问题。
- 自旋锁循环时间长开销较大。
- 只能保证一个共享变量的原子操作。
5.7 CAS中ABA问题
CAS算法实现的重要前提是取出内存中某时刻的值并在当前时刻进行比较替换,在这个时间差内会导致数据发生变化。例如线程1从内存中读取的值为A,这个时候线程2也从内存中取出值A,并且线程2将这个值改为B后然后又改回A,这时线程1进行CAS操作发现内存值和期望值都是A操作成功,这就导致了CAS中ABA的问题。
解决方法: 添加版本号,可以通过 Java 自身提供的 AtomicStampedReference(原子引用变量)来解决 ABA 的问题,类似于乐观锁。