多线程进阶
1. 常见的锁策略
1.1. 乐观锁和悲观锁
乐观锁
加锁的时候,假设出现锁冲突的概率不大,接下来围绕加锁要展开的工作就很少
悲观锁
加锁的时候,假设出现锁冲突的概率很大,接下来围绕加锁要展开的工作就会很多
synchronized锁在初始情况下是乐观锁,预估接下来出现锁冲突的概率不大,同时会统计锁冲突的次数,达到一定程度之后就会转化为悲观锁
1.2. 挂起等待锁和自旋锁
挂起等待锁:当一个线程试图获取一个已经被其他线程持有的挂起等待锁时,该线程会被阻塞(挂起),操作系统会将其状态从运行态转换为阻塞态,并将 CPU 资源让给其他可运行的线程。这个被阻塞的线程会被放入一个等待队列中,直到持有锁的线程释放锁后,操作系统才会将该线程从等待队列中唤醒,重新将其状态转换为就绪态(可运行态),等待 CPU 分配时间片来继续执行。
适用于一个线程获取锁需要很长时间的情况
自旋锁:自旋锁是一种忙等待的锁机制。当一个线程试图获取一个已被其他线程持有的自旋锁时,它不会被阻塞,而是会在一个循环中不断地检查锁是否已经被释放。
适用于锁冲突概率小并且锁持有时间短的情况,否则CPU开销会非常大
1.3. 重量级锁和轻量级锁
重量级锁和轻量级锁是在加锁开销的角度定义的,也就是时间的开销
重量级锁:加锁的开销比较大,要做更多的工作(悲观锁做的重)
重量级锁就是基于挂起等待的方式实现的(调用操作系统api,在内核中实现的,效率低)
轻量级锁:加锁的开销比较小,要做的工作也相对少(乐观锁做的轻)
轻量级锁就是基于自旋锁的方式实现的,(JVM内部,用户态代码实现,效率高)
1.4. 公平锁和非公平锁
公平锁:遵循先到先得的原则实现
非公平锁:任何一个线程都可以获得锁,各凭本事争夺,但是也不是每个线程概率均等
1.5. 可重入锁和不可重入锁
可重入锁在之前提到过,synchronized就是可重入锁,如果一个线程针对同一把锁连续加锁两次,就可能出现死锁,如果把锁设定成可重入就可以避免死锁了,实现步骤大概有以下几个操作
- 记录当前是哪个线程持有了锁
- 在加锁的时候判定,当前申请锁的线程是否是锁的持有者线程
- 定义一个计数器来记录加锁的次数,从而确定何时真正的加锁
不可重入锁就是不能同时加锁两次的锁
1.6. 读写锁
读写锁用于在多线程环境下对共享资源进行并发访问的控制,读写锁将共享资源的访问分为读操作和写操作,并针对这两种操作进行不同的并发控制
读操作:允许两个线程同时获取读锁,并进行读操作,因为读操作并不会改变共享资源的状态
写操作:当一个线程获取写锁时,其他线程无论是要读还是写都不能再获取锁
在 Java 中,ReentrantReadWriteLock
类提供了读写锁的实现,可以使用内部类ReadLock的readLock().lock()
方法来获取读锁,使用writeLock().lock()
方法来获取写锁
ReentrantReadWriteLock
还支持可重入性,即同一个线程可以多次获取读锁或者写锁。例如,一个线程已经获取了读锁,在不释放读锁的情况下可以再次获取读锁;同样,一个线程已经获取了写锁,也可以再次获取写锁。
2. synchronized
synchronized 在以上锁策略中属于:
- 乐观锁&悲观锁(自适应)
- 轻量级锁&重量级锁(自适应)
- 自旋锁&挂起等待锁(自适应)
- 非公平锁
- 可重入锁
- 非读写锁
synchronized 的加锁过程:刚开始使用 synchronized 加锁,首先会处于“偏向锁”的状态,遇到线程之间的锁竞争,会升级到“轻量级锁”,之后进一步统计竞争出现的频次,达到一定程度之后,升级到“重量级锁”
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
上面锁升级的过程,对 JVM 来说是不可逆的
偏向锁:并不是真正的加锁(真正加锁时开销可能比较大,偏向锁只是做个标记),标记的过程非常轻量高效
3. 锁的优化机制
3.1. 锁消除
JVM 在编译时和运行时会进行代码分析,如果发现某些对象的锁操作是不必要的,就会将这些锁操作消除。例如,在一些局部对象上的锁操作,如果这个局部对象不会被多个线程共享,那么这些锁操作就是多余的,或者说在单线程环境下加锁,也是会被优化掉的。
3.2. 锁粗化
锁粗化是指将多个连续的、对同一个对象的同步块合并成一个更大的同步块。也就是把多个“细粒度”的锁合并成“粗粒度”的锁,例如,如果在一个循环体内多次对同一个对象进行锁操作,JVM 可能会将这些锁操作合并成一个锁操作,在循环开始前获取锁,在循环结束后释放锁。
4. CAS
CAS(Compare - And - Swap),即比较并交换,是一种用于实现多线程同步的原子操作机制
一个内存中的数据和两个寄存器中的数据进行操作(寄存器1,寄存器2):
比较内存和寄存器1中的值是否相等,如果相等,就交换寄存器2的值和内存中的值,这里一般都是关心内存交换后的内容,不关心寄存器2交换后存储的内容,虽然叫做交换,其实希望达成的效果是赋值
CAS 操作是原子性的,能够在多线程环境下确保数据的一致性。它不需要像传统的锁机制那样进行加锁、解锁操作,避免了锁带来的诸如死锁、线程阻塞和唤醒等复杂问题
应用场景:
4.1. 基于CAS实现的“原子类”
int / long 等类型进行 ++ -- 的操作时,并不是原子的,基于CAS实现的原子类,对 int / long 等这些类型进行了封装,从而可以使用原子的 ++ -- 操作
public class ThreadDemo26 {
//private static int count = 0;
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count.getAndIncrement();
// count.incrementAndGet(); ++ count
// count.getAndDecrement(); count --
// count.decrementAndGet(); -- count
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
来看一下CAS实现的原子类大概是怎么工作的:
4.2. 实现自旋锁
来看一下大概的逻辑
4.3. ABA问题
CAS 操作存在 ABA 问题:假设一个共享变量初始值为 A,线程 1 读取到这个值 A 后,被其他线程干扰,这个变量先被修改为 B,然后又被修改回 A。当线程 1 再次执行 CAS 操作时,它会认为这个变量没有被修改过(因为还是 A),从而可能导致一些潜在的逻辑错误。
解决办法:添加版本号或者时间戳等辅助手段来解决 ABA 问题。例如,将共享变量从单纯的数值 A 变成一个包含版本号的结构体,如<A, 1>
,每次修改都会更新版本号,这样线程 1 再次执行 CAS 操作时就能发现这个变量实际上已经被修改过了。
产生ABA问题其实也就是变量加完之后又减回原来的值,出现了减法,引入版本号这样只能增加的标记,就可以解决了
5. JUC
5.1. Semaphore
Semaphore(信号量)是一个用于控制同时访问特定资源的线程数量的同步工具类。它维护了一个许可证(permit)的概念,线程在访问共享资源之前必须先获取许可证,在使用完共享资源后释放许可证。
public class ThreadDemo27 {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(3);
semaphore.acquire();
System.out.println("申请一个资源");
semaphore.acquire();
System.out.println("申请一个资源");
semaphore.acquire();
System.out.println("申请一个资源");
semaphore.release();
System.out.println("释放一个资源");
semaphore.acquire();
System.out.println("申请一个资源");
}
}
通过信号量,也可以解决之前的线程安全问题:
只需要把信号量的大小创建为 1 ,之后就相当于锁,每次执行代码时申请资源,执行完毕之后释放资源,由于资源只有一份,其他线程也拿不到
5.2. CountDownLatch
CountDownLatch
是一个同步辅助类,用于协调多个线程之间的执行顺序, 它允许一个或多个线程等待其他线程完成一系列操作。CountDownLatch
内部维护着一个计数器,这个计数器初始化为一个正整数,表示需要等待的事件数量。
await
方法: 调用这个方法的线程会被阻塞,直到CountDownLatch
的计数器值为 0
countDown
方法: 这个方法用于将CountDownLatch
的计数器值减 1。当计数器值达到 0 时,所有等待的线程(调用await
方法被阻塞的线程)将被唤醒并继续执行。
public class ThreadDemo28 {
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(4);
CountDownLatch count = new CountDownLatch(20);
for(int i = 0;i < 20;i++){
int id = i;
executorService.submit(()->{
System.out.println("任务" + id + "开始执行");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
System.out.println("任务" + id + "执行完毕");
//每执行一次,任务数 -1
count.countDown();
}
//等待所有任务完成
count.await();
System.out.println("执行完毕");
}
}
6. 线程安全的集合类
在之前提到过,在使用 ArrayList,Queue 这样的集合时,它的线程是不安全的,虽然说 Java 中提供了 Vector ,Stack ,Hashtable 这样内置了 synchronized 集合,但是这几个东西并不常用,容易出现死锁
6.1. ArrayList / LinkedList 的优化
如果需要使用 ArrayList / LinkedList 这样的结构,Java 提供了一个带锁的 List
Collections.synchronizedList(new ArrayList<>());
这就相当于构造了一个核心方法中带 synchronized 的 List
还可以使用 CopyOnWrite 集合类,这个集合类没有采用加锁的方式,而是通过“写时拷贝”来实现线程安全的,以此避免两个线程同时修改一个变量,如果说只是读取,ArrayList 不需要进行任何改变,如果有其他线程修改上面的元素,此时不会直接进行修改,而是拷贝出一份新的 ArrayList ,在拷贝的过程中,读操作仍然是读取旧版本的内容,写操作则是在新版本上进行修改,然后引用再指向新版本。
但是写时拷贝无法应对多个线程同时修改的情况,如果涉及到的数据量很大,拷贝起来也比较费时间,拷贝机制其实主要应对的就是“多个线程读,一个线程写这样的场景”
也就类似于广告的服务器,在运行的时候涉及到很对配置,这些配置都是写在配置文件中的,服务器运行时加载配置文件中的配置项,根据配置项来打开 / 关闭 / 设置一些功能,如果此时想要修改配置文件,就可以用到上面的“写时拷贝”来实现
6.2. Queue 的优化
多线程环境下的队列其实就可以使用之前提到的 BlockingQueue 。
6.3. HashMap 的优化
虽然说 Java 提供了 Hashtable , 但是还有一个更优秀的集合类 , 也是比较推荐的
ConcurrentHashMap,相比于Hashtable 和 HashMap ,做出了很多优化
- 优化了锁的粒度。Hashtable 加锁,就是直接给所有的方法都加上 synchorized ,这样整个 Hashtable 就是一把锁,针对这个哈希表的操作都可能会发生锁的竞争,而ConcurrentHashMap 是给每一个哈希表中的 “链表”加锁(加了多把锁,“锁桶”),只有同时进行的两次修改,恰好在同一条链表上的元素时,才会触发锁竞争,大大降低了锁冲突的概率
- ConcurrentHashMap 引入了 CAS 原子操作,针对想 size 这样的操作,直接借助 CAS 完成,并不会加锁
- 针对读操作也做了特殊处理,上面的加锁都是针对写操作来加锁的,对于读操作,通过 Volatile 以及其他一些代码来实现,确保读操作不会读到“修改一半”的数据
- 针对哈希表的扩容机制进行了优化,普通的哈希表扩容是创建新的哈希表,把原来的数据搬进去,这一系列操作可能一次就搬运玩了,整体时间开销会很大,如果再使用锁,就出现长时间占用锁的情况,而ConcurrentHashMap 不会在一次操作中把全部的数据搬过去,而是只搬一部分,每次都搬一部分,最终全部搬运完成,这样的话新表和旧表同时存在,如果再涉及到插入操作,直接插入到新的空间中,如果是查询 / 修改 / 删除,就需要同时对两个表进行操作,不过由于哈希表的这些操作都是 O(1) 的时间复杂度,也不会有太大的消耗