一、公平锁与非公平锁
这组概念描述的是线程获取锁的策略。
公平锁
顾名思义,它追求"先来后到"的公平原则。线程在请求锁时,会先进入一个等待队列,排在队首的线程才有机会获取锁。
优点: 避免了饥饿现象(某个线程一直抢不到锁)。
缺点: 性能较低,因为每次获取锁都需要进行队列操作,有额外的开销。
非公平锁
允许"插队"。当一个新线程请求锁时,它会先尝试获取,如果成功就直接拿到锁,不用排队。只有当尝试失败时,它才会进入等待队列。
- 优点: 性能更高,因为减少了线程调度的开销。
- 缺点: 可能导致某些线程长时间无法获取锁而"饿死"。
公平锁和非公平锁的优缺点互补。
应用场景与选择:
ReentrantLock
默认是非公平锁 ,可以在构造函数中指定为公平锁:new ReentrantLock(true)
。
非公平锁的优点在于吞吐量比公平锁大。
只有当你对线程获取锁的顺序有严格要求,或需要避免饥饿问题时,才考虑使用公平锁。
对于 Syncronized 而言,也是一种非公平锁;由于其并不像 ReentrantLock 是通过 AQS 来实现线程调度,所以不可能变成公平锁。
二、可重入锁
可重入锁指的是同一个线程可以多次获取同一把锁而不会被自己阻塞。
synchronized
和 ReentrantLock
都是典型的可重入锁。
对于ReentrantLock
而言, 他的名字就可以看出是一个可重入锁,其名字是Re entrant Lock
重新进入锁。
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。
为什么需要可重入? 考虑一个场景:一个同步方法调用了另一个同步方法。
kotlin
synchronized void methodA() {
// ...
methodB();
}
synchronized void methodB() {
// ...
}
如果锁不可重入,当线程A执行 methodA
并获取锁后,在调用 methodB
时会再次尝试获取锁。由于锁已经被自己持有,如果不可重入,线程A就会永远等待,造成死锁。可重入锁解决了这个问题。
三、独享锁与共享锁
这组概念是从锁的资源独占性角度来分类的。
- 独享锁是指任意时刻只允许一个线程持有锁。
synchronized
和 ReentrantLock
都是独享锁。
应用场景: 对资源进行修改操作时,必须保证数据一致性,因此需要独享锁来保证独占性。
- 共享锁是指该锁可被多个线程所持有。
应用场景 : 读多写少的场景。多个线程可以同时读取数据,互不干扰。
ReentrantReadWriteLock
的**读锁(ReadLock)**就是共享锁
读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
四、互斥锁与读写锁
这组概念与上一个非常相似,但更侧重于功能划分。独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。
- 互斥锁(Mutex Lock) : 排他性 的锁,任意时刻只有一个线程能持有。这是独享锁的另一种叫法。
synchronized
和ReentrantLock
都属于互斥锁。 - 读写锁(Read-Write Lock) : 维护了一对锁:读锁和写锁。
- 读锁是共享的:多个线程可以同时获取读锁。
- 写锁是独占的:只有当没有其他线程持有读锁或写锁时,才能获取写锁。
比如在安卓中有一个全局的配置类,在应用启动时加载一次,之后大部分时间都是读取,偶尔有后台任务会更新它,那么使用 ReentrantReadWriteLock
是一个非常好的选择。它能在保证数据安全的同时,显著提升读取操作的并发性能。
五、乐观锁与悲观锁
乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。
- 悲观锁(Pessimistic Lock) : 总是假设最坏的情况,认为数据在处理过程中肯定会被其他线程修改,因此在操作数据之前先上锁 。
synchronized
和ReentrantLock
都属于悲观锁,它们在访问共享资源前,会先获取锁。 - 乐观锁(Optimistic Lock) : 总是假设最好的情况,认为数据在处理过程中不会被其他线程修改。在操作数据时不加锁 ,而是在更新数据时,通过版本号(version) 或 CAS(Compare-And-Swap) 算法来判断数据是否被修改过。
CAS原理: 比较内存中的值与预期值是否一致,如果一致则更新为新值,否则失败。通过自旋(循环尝试)实现轻量级同步,减少内核态切换。
应用场景 : 数据库的版本号机制 就是乐观锁的典型应用。在Java中,
AtomicInteger
、AtomicLong
等原子类就是通过CAS实现的,常用于高并发计数器等场景。
CAS 使用 示例(基于 AtomicInteger):
kotlin
import java.util.concurrent.atomic.AtomicInteger;
public class SimpleCASDemo {
public static void main(String[] args) {
// 1. 初始化原子整数,初始值为0
AtomicInteger atomicInt = new AtomicInteger(0);
// 2. 尝试通过CAS将值从0更新为1
boolean success = atomicInt.compareAndSet(0, 1);
// 3. 输出CAS操作结果和当前值
System.out.println("CAS操作是否成功: " + success); // 输出: true
System.out.println("当前值: " + atomicInt.get()); // 输出: 1
// 4. 再次尝试通过CAS将值从0更新为2(此时当前值已是1,预期值不匹配)
success = atomicInt.compareAndSet(0, 2);
// 5. 输出第二次操作结果和当前值
System.out.println("CAS操作是否成功: " + success); // 输出: false
System.out.println("当前值: " + atomicInt.get()); // 输出: 1
}
}
从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。
我们都知道 CAS 有一个经典的 ABA 问题,解决 ABA 问题的方法就是加个版本号,比如使用 AtomicStampedReference
:
java
import java.util.concurrent.atomic.AtomicStampedReference;
public class ABASolutionExample {
// 使用版本号来解决 ABA 问题
private static AtomicStampedReference<String> atomicRef =
new AtomicStampedReference<>("A", 0);
public static void main(String[] args) throws InterruptedException {
String initialRef = atomicRefgetReference();
int initialStamp = atomicRef.getStamp();
System.out.println("初始值: " + initialRef + ", 版本号: " + initialStamp);
Thread thread1 = new Thread(() -> {
try {
// 模拟一些操作
Thread.sleep(1000);
// 尝试将 A 改为 B
boolean success = atomicRef.compareAndSet("A", "B",
initialStamp, initialStamp + 1);
System.out.println("线程1修改结果: " + success);
// 再改回 A
success = atomicRef.compareAndSet("B", "A",
initialStamp + 1, initialStamp + 2);
System.out.println("线程1改回A结果: " + success);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread thread2 = new Thread(() -> {
try {
// 模拟长时间操作
Thread.sleep(2000);
// 此时虽然值还是 A,但版本号已经改变
boolean success = atomicRef.compareAndSet("A", "C",
initialStamp, initialStamp + 1);
System.out.println("线程2修改结果: " + success);
System.out.println("当前值: " + atomicRef.getReference() +
", 当前版本号: " + atomicRef.getStamp());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
}
}
六、分段锁
分段锁是一种锁的细化技术 ,它把一个大的数据结构(如 ConcurrentHashMap
)分成多个小的段(Segment),每个段都有独立的锁。
对 ConcurrentHashMap
的一个段进行写操作时,只锁定这个段,其他线程仍然可以对其他段进行读写操作。
我们以ConcurrentHashMap
来说一下分段锁的含义以及设计思想,ConcurrentHashMap
中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。
当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。
ConcurrentHashMap
在JDK 1.7中就是通过分段锁实现的,这大大提升了其并发性能。在JDK 1.8中,ConcurrentHashMap
引入了CAS+synchronized的策略,不再使用分段锁,但分段锁的思想仍然非常重要。
小结:
在 JDK 1.7 中,
ConcurrentHashMap
采用"分段锁"机制,将数据分为多个Segment
,每个Segment
相当于一个ReentrantLock
+ 小型HashMap
。多线程
put
时,根据hash
定位到不同Segment
,实现并行写入。但在统计
size()
时,需获取所有Segment
的锁,性能较差。从 JDK 1.8 开始,
ConcurrentHashMap
被彻底重构,放弃Segment
,改用Node
数组 +synchronized
锁节点 +CAS
操作,结构更简单,并发性能更高。
七、JVM 级别的锁优化:偏向锁、轻量级锁、重量级锁
这组概念是synchronized
关键字在JVM底层的实现原理,用于在不同竞争程度下,对锁进行优化。
- 偏向锁(Biased Locking) : 锁的初级状态。当一个线程第一次获取锁时,JVM会将锁标记为"偏向"该线程。之后,该线程再次进入同步块时,无需任何同步操作,直接执行。
- 应用场景: 只有一个线程反复进入同步块的场景。
- 轻量级锁(Lightweight Locking) : 锁的升级状态 。当另一个线程尝试获取偏向锁时,偏向锁会升级为轻量级锁。这个过程通过CAS操作来完成。线程会在自己的栈帧中创建一个锁记录(Lock Record),并通过CAS将锁的Mark Word(存储在对象头中)指向该锁记录。如果CAS成功,则获取锁。
- 应用场景: 多个线程交替执行同步块,但没有竞争冲突。
- 重量级锁(Heavyweight Locking) : 锁的最高状态。当轻量级锁CAS失败时(当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,CAS 失败),意味着存在真正的竞争冲突。JVM会把锁升级为重量级锁。此时,没有获取锁的线程会被阻塞,进入内核态的等待队列,等待操作系统的调度。重量级锁会让其他申请的线程进入阻塞,性能降低。
- 应用场景: 多个线程同时竞争锁,导致同步块长时间执行。
小结:
偏向锁、轻量级锁、重量级锁是JVM为了提升 synchronized
性能而做的自动优化 。它是一个锁升级的过程:
无竞争 -> 偏向锁 -> 轻微竞争 -> 轻量级锁 -> 激烈竞争 -> 重量级锁。
并且锁状态只可升级不可降级!
八、自旋锁
自旋锁是一种特殊的锁,它在获取锁失败时,不会立即将线程阻塞,而是会**循环(自旋)**地尝试获取锁。
线程会执行一个忙等循环,不断检查锁是否被释放。
- 优点: 避免了线程从用户态切换到内核态的开销,这在锁持有时间很短的情况下,性能非常高。
- 缺点: 如果锁被长时间占用,自旋会浪费CPU资源,造成性能下降。
JVM在某些情况下也会使用自旋锁来优化 synchronized
。当一个线程尝试获取锁失败时,它会自旋一段时间,如果在这段时间内锁被释放,它就能立即获取,从而避免了线程上下文切换。
应用场景:
Atomic
系列类(如AtomicInteger
)的底层就是通过CAS+自旋实现的,它能保证操作的原子性,同时避免了锁的开销。- 当锁的粒度非常小,且锁的持有时间极短时,可以考虑使用自旋锁。
如果你细看 CAS,可能会有以下疑问(没错也包括我):
CAS 操作大致可以分为以下步骤
- 读取当前值(作为预期值)
- 进行一些计算,得到新值
- 比较当前值是否还是预期值
- 如果是,就设置新值
如果这四步是分开的,那么在第3步和第4步之间,另一个线程完全可能修改了这个值,导致第4步的"设置"操作失去了意义(即不是原子的)。
其实你再深入一下会发现 CAS 不是 Java 或高级语言实现的一个"函数",而是一个由 CPU 硬件直接支持的、不可分割的原子指令。根本不存在一个独立的"设置"阶段。"比较"和"可能的设置"是同一个硬件指令的一部分。
"读取内存值 -> 与预期值比较 -> (如果相等)写入新值" 这整个序列是一个不可中断的操作。
总的来说这个过程是由硬件层支持的,作为应用层的我们,只需要知道就行了,这种担心是多虑的。
九、最后
最近的几篇文章都是关于 Java 的,其实为是干安卓的,但是 Java 也是安卓的基础,这些概念知识也是通往高级开发的必经之路,可能你看完文章会发现对自己的开发帮助也不大,都是一些概念类的,但是这些东西对你理解和学习更深层次的知识是有帮助的,最后要看你怎么去理解消化了。
Java 相关的文章就先写这么多,后续再查漏补缺安卓知识,了解-学习-消化-理解-输出。