- 静态方法的锁是所在类的 Class 对象,普通方法的锁是 this 对象。
针对同一个线程,synchronized 锁可以支持重入。
public class T {
public synchronized void a1() {}
public void a2() {}
}
t1 线程执行 a1 方法时要读取 this 对象锁,但 t2 线程执行 a2 并不需要锁。没有交集。
public class T {
public synchronized void a1() {}
public synchronized void a2() {}
}
一个同步方法可以调用另一个同步方法,一个线程已经拥有某个对象的锁。两次申请时仍然会得到该对象锁。synchronized 是可重入的,粗浅认为在某个状态变量上 +1
操作。
子同步方法可以调用父的 synchronized mehtod ? 因为锁的对象都是 this,所以是可以的。
活跃性问题
- 死锁
- 活锁
-
Java 对象构成
在 JVM 中,对象在内存中分为三块区域:
对象头
- Mark Word(标记字段):默认存储对象的 HashCode、分代年龄和锁标志位信息。它会根据对象状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
- Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
- 实例数据:这部分主要是存放类的数据信息,父类的信息。
-
锁升级过程
无锁 -> 偏向锁 -> 轻量级锁(CAS)-> 重量级锁
偏向锁:一旦有线程持有这个对象,标志位修改为 1,就进入偏向锁,同时会把这个线程的 ID 记录在对象的 Mark Word 中。这个过程使用 CAS 并发操作。每次同一个线程进入,对标志位 +1 即可。
- 轻量级锁:和 Mark Word 相关。如果这个对象是无锁,jvm 就会在当前线程的栈帧中建立一个叫锁记录(Lock Record)的空间,用来存储锁对象的 Mark Word 拷贝,然后把 Lock Record 中的 owner 指向当前对象。JVM 接下来会复用 CAS 尝试把对象原本的 Mark Word 更新为 Lock Record 的指针,成功就说明加锁成功,改变锁标志位。如果失败,就会判断当前对象的 Mark Word 是否指向了当前线程的栈帧,如果是则表示当前线程已经持有了这个对象的锁,否则表示其它线程持有了,继续锁升级,修改锁的状态,之后也阻塞等待。
Synchronized 和 Lock 对比
| 功能 | synchronized | lock | | —- | —- | —- | | 实现方式 | JVM | 用户层代码实现 | | 是否主动释放锁 | 是 | 否,需要手动释放 | | 是否可中断 | 不可中断 | 可任意配置 | | 是否能锁方法 | 是 | 否 | | 是否能锁代码块 | 是 | 是 | | 有无读写锁 | 无 | 有 | | 是否公平锁 | 否,非公平锁 | 可配置 |
内存模型
缓存一致性问题
主存与 CPU 处理器的运算能力之间有数量级差距,在传统计算机内存架构中会引入高速缓存来作为主存和处理器之间的缓冲,CPU 将常用的数据放在高速缓存中,运行结束后 CPU 再将结果同步到主存中。使用高速缓存解决速率不匹配问题,但同时也引入一个新问题:缓存一致性问题。
因此,需要每个 CPU 访问缓存时遵循一定的协议,在读写数据时根据协议进行操作,共同维护缓存一致性。这类协议有:
处理器优先和指令重排序
为了使处理器内部的运算单元能够最大化被充分利用,处理器会对输入代码进行乱序处理。
- 编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
并发编程的问题
可见性
- 原子性
- 有序性
这三个问题本质是由缓存一致性、处理器优化、指令重排序所的造成的。
Java 线程与主内存关系
Java 内存模型是一种规范,定义了很多东西:
- 所有的变量都存储在主内存(Main Memory)中。
- 每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的拷贝副本。
- 线程对变量的所有操作都必须在本地内存中进行,而不能直接读写主内存。
- 不同的线程之间无法直接访问对方本地内存中的变量。
为了更好的控制主内存和本地内存的交互,Java 内存模型定义了八种操作来实现:
- lock:锁定。作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock:解锁。作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read:读取。作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load:载入。作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use:使用。作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign:赋值。作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store:存储。作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
- write:写入。作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
CAS
CAS 是比较并交换之意,它是一条 CPU 并发原语,用于判断内存中某个值是否为预期值,如果是则更改为新的值,这个过程是原子的。CAS 基本原理
CAS 主要包括两个操作:Compare和Swap,有人可能要问了:两个操作能保证是原子性吗?可以的。
CAS 是一种系统原语,原语属于操作系统用语,原语由若干指令组成,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说 CAS 是一条 CPU 的原子指令,由操作系统硬件来保证。在 Intel 的 CPU 中,使用 cmpxchg 指令。
回到 Java 语言,JDK 是在 1.5 版本后才引入 CAS 操作,在sun.misc.Unsafe这个类中定义了 CAS 相关的方法。 ```java public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
public final native boolean compareAndSwapLong(Object o, long offset, long expected, long x);
可以看到方法被声明为 native,如果对 C++ 比较熟悉可以自行下载 OpenJDK 的源码查看 unsafe.cpp,这里不再展开分析。
<a name="mbye8"></a>
## CAS 带来的问题
1. 典型的 ABA 问题。假设有一个变量,初始化为 A,然后又修改为 B,然后又被修改为 A。这个变量实际被修改过,但是 CAS 操作可能无法感知到。解决思路:加一个版本号。
1. 自旋开销。CAS 出现冲突就开始自旋操作,如果竞争非常激烈,自旋长时间不能成功就会给 CPU 带来非常大的开销。
1. 只能保证单个变量的原子性。
<a name="IvOPx"></a>
# Atomic 原子类实现原理
位于 `java.util.concurrent.atomic` 包,这里包含了多个原子操作类。<br />Atomic 包下的原子操作类有很多,可以大致分为四种类型:
- 原子操作**基本类型**
- 原子操作**数组类型**
- 原子操作**引用类型**
- 原子操作**更新属性**
<a name="TcpNI"></a>
## AtomicInteger 源码分析
实际上使用 `Unsafe` 完成操作。
```java
public final class Unsafe {
// ……省略其他方法
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
// 循环 CAS 操作
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
// 根据内存偏移地址获取当前值
public native int getIntVolatile(Object o, long offset);
// CAS 操作
public final native boolean compareAndSwapInt(Object o, long offset,
int expected,
int x);
}
AtomicLong 和 LongAdder 谁更牛?
Java 在 jdk1.8版本 引入了 LongAdder 类,与 AtomicLong 一样可以实现加、减、递增、递减等线程安全操作,但是在高并发竞争非常激烈的场景下 LongAdder 的效率更胜一筹,后续单独用一篇文章进行介绍。
Java 18 把锁
乐观锁和悲观锁
悲观锁
在 Java 语言中 synchronized 和 ReentrantLock等就是典型的悲观锁,还有一些使用了 synchronized 关键字的容器类如 HashTable 等也是悲观锁的应用。
乐观锁
乐观锁可以使用版本号机制和 CAS 算法实现。在 Java 语言中 java.util.concurrent.atomic
包下的原子类就是使用 CAS 乐观锁实现的。
两种锁的使用场景
悲观锁和乐观锁没有孰优孰劣,有其各自适应的场景。
乐观锁适用于写比较少(冲突比较小)的场景,因为不用上锁、释放锁,省去了锁的开销,从而提升了吞吐量。
如果是写多读少的场景,即冲突比较严重,线程间竞争激励,使用乐观锁就是导致线程不断进行重试,这样可能还降低了性能,这种场景下使用悲观锁就比较合适。
独占锁和共享锁
独占锁
锁只能被一个线程持有。获得独占锁的线程既能读数据又能修改数据。
JDK 中的 synchronized 和 java.util.concurrent(JUC)
包中 Lock 的实现类就是独占锁。
共享锁
锁可被多个线程持有。如果一个线程对数据加上共享锁后,那么其他线程只能对数据再加共享锁,不能加独占锁。获取共享锁的线程只能读数据,不能修改数据。
在 JDK 中 ReentrantReadWriteLock
就是一种共享锁。
互斥锁和读写锁
互斥锁
互斥锁是独占锁的一种常规实现,指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。
互斥锁一次只能一个线程拥有互斥锁,其他线程只有等待。
读写锁
读写锁是共享锁的一种具体实现。读写锁管理一组锁,一个是只读的锁,一个是写锁。
读锁可以在没有写锁的时候被多个线程同时持有,而写锁是独占的。写锁的优先级要高于读锁,一个获得了读锁的线程必须能看到前一个释放的写锁所更新的内容。
读写锁相比于互斥锁并发程度更高,每次只有一个写线程,但是同时可以有多个线程并发读。
ReadWriteLock
公平锁和非公平锁
公平锁
非公平锁
可重入锁
又称递归锁,指同一个线程在外层方法获取了锁,在进入内层方法会自动获取锁。
ReentrantLock、synchronized
自旋锁
指线程在没有获取锁时不是被直接挂起,而是执行一个忙循环,即所谓的自旋。
JDK 1.6 引入的自适应自旋,
分段锁
分段锁是一种锁的设计,而非特指一种锁。
分段锁设计目的是将锁的粒度进一步细化,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
锁升级
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
偏向锁
Java偏向锁(Biased Locking)是指它会偏向于第一个访问锁的线程,如果在运行过程中,只有一个线程访问加锁的资源,不存在多线程竞争的情况,那么线程是不需要重复获取锁的,这种情况下,就会给线程加一个偏向锁。
偏向锁的实现是通过控制对象Mark Word的标志位来实现的,如果当前是可偏向状态,需要进一步判断对象头存储的线程 ID 是否与当前线程 ID 一致,如果一致直接进入。
轻量级锁
当线程竞争变得比较激烈时,偏向锁就会升级为轻量级锁,轻量级锁认为虽然竞争是存在的,但是理想情况下竞争的程度很低,通过自旋方式等待上一个线程释放锁。
重量级锁
如果线程并发进一步加剧,线程的自旋超过了一定次数,或者一个线程持有锁,一个线程在自旋,又来了第三个线程访问时(反正就是竞争继续加大了),轻量级锁就会膨胀为重量级锁,重量级锁会使除了此时拥有锁的线程以外的线程都阻塞。
升级到重量级锁其实就是互斥锁了,一个线程拿到锁,其余线程都会处于阻塞等待状态。
在 Java 中,synchronized 关键字内部实现原理就是锁升级的过程:无锁 —> 偏向锁 —> 轻量级锁 —> 重量级锁。这一过程在后续讲解 synchronized 关键字的原理时会详细介绍。
锁优化技术(锁粗化、锁消除)
锁粗化
锁粗化就是将多个同步块的数量减少,并将单个同步块的作用范围扩大,本质上就是将多次上锁、解锁的请求合并为一次同步请求。
举个例子,一个循环体中有一个代码同步块,每次循环都会执行加锁解锁操作。
private static final Object LOCK = new Object();
for(int i = 0;i < 100; i++) {
synchronized(LOCK){
// do some magic things
}
}
经过锁粗化后就变成下面这个样子了:
synchronized(LOCK){
for(int i = 0;i < 100; i++) {
// do some magic things
}
}
锁消除
锁消除是指虚拟机编译器在运行时检测到了共享数据没有竞争的锁,从而将这些锁进行消除。
举个例子让大家更好理解。
public String test(String s1, String s2){
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(s1);
stringBuffer.append(s2);
return stringBuffer.toString();
}