多线程进阶
- 常见的所策略
- 乐观锁和悲观锁
- 重量级锁和轻量级锁
- 自旋锁和挂起等待锁
- 自旋锁
- 挂起等待锁
- 读写锁和互斥锁
- 读写锁
- 互斥锁
- 公平锁和非公平锁
- 公平锁
- 非公平锁
- 可重入锁和不可重入锁
- 可重入锁
- 不可重入锁
- CAS
- CAS应用
- 实现原子类
- 实现自旋锁
- CAS的ABA问题
- synchronized原理
- 基本特点
- 加锁工作过程
- 偏向锁
- 轻量级锁
- 重量级锁
- 其他的优化操作
- 锁消除
- 锁粗化
- JUC(java.util.concurrent)的常见的类
- callable接口
- ReentrantLock
- 用法
- 原子类
- 线程池
- ExecutorService和Executors
- 信号Semaphore
- CountDownLatch
- 线程安全的集合类
- 多线程环境使用ArrayList
- 多线程环境使用队列
- 多线程环境使用哈希表
- Hashtable
- ConcurrentHashMap
- 死锁
- 如何避免死锁
常见的所策略
乐观锁和悲观锁
乐观锁
乐观锁的基本思想是假设在数据的读取和修改过程中不会有其他的线程对其进行修改,,因此乐观锁不会立即对数据进行加锁,而是在更新数据时检查是否发生了冲突,如果发现冲突(即数据被其他线程修改),则会进行回滚操作,乐观锁通常使用版本号,时间戳等机制来实现
优点:适用于读操作频繁,写操作较少的场景,可以有效提高并发性能
缺点:需要重试机制,会增加系统的开销,对于写操作的场景,可能会导致较多的冲突和重拾,性能下降
悲观锁
悲观锁的基本思想是在数据读取和修改过程中假设会有七大的线程对其进行修改,因此在访问数据钱会先加锁,确保其他线程无法同时修改数据,从而保证数据的一致性和安全性
优点:能有效的保证数据的一致性和安全性,适用于写操作频繁的场景
缺点:加锁和释放锁的开销比较大,会降低并发性能,对于写操作较少的场景,可能会导致不必要的阻塞和竞争
重量级锁和轻量级锁
重量级锁:
重量级锁是基于操作系统的互斥量(Mutex)实现的一种锁机制。当线程需要获取重量级锁时,会进入阻塞状态,操作系统会将该线程调度到内核态,并将其挂起,直到锁被释放。重量级锁涉及用户态与内核态之间的切换,因此开销较大。
重量级锁适用于多线程竞争激烈、锁竞争时间较长的情况。它可以保证线程安全性,但在高并发场景下可能导致性能瓶颈。
轻量级锁:
轻量级锁是在Java虚拟机内部实现的一种优化手段。它通过在对象头部记录锁记录信息(如线程ID)来实现线程同步。当线程尝试获取轻量级锁时,如果成功,会将锁记录的信息更新为当前线程的ID,并进入临界区代码执行;如果失败,说明存在锁竞争,转而升级为重量级锁。
轻量级锁避免了用户态与内核态之间的切换,减少了锁竞争带来的开销。它适用于锁竞争不激烈、锁持有时间较短的情况。在这种情况下,轻量级锁可以提供更好的性能表现。
需要注意的是,虽然轻量级锁减少了锁竞争开销,但当锁竞争激烈时,轻量级锁会频繁地自旋(Spin)尝试获取锁,这可能会消耗CPU资源。当自旋次数达到一定阈值时,轻量级锁会膨胀为重量级锁,以避免无谓的自旋。
自旋锁和挂起等待锁
自旋锁
按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度.
但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个
时候就可以使用自旋锁来处理这样的问题.
伪代码
while (抢锁(lock) == 失败) {}
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会
在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.
优点:没有放弃cpu,不涉及线程的阻塞和调度,一旦锁被释放,就能第一时间获取到锁
缺点:如果锁被其他线程持有的时间比较久,那么就会持续消耗cpu资源(而挂起等待的时候是不消耗资源的)
挂起等待锁
挂起等待锁是一种基于线程的阻塞/唤醒机制的锁机制。当一个线程尝试获取挂起等待锁时,如果锁已被其他线程占用,该线程会处于等待状态,释放CPU资源,直到锁的持有者释放锁后被唤醒。
挂起等待锁适用于锁竞争持续时间较长、线程数量较多的情况。
优点:挂起等待锁避免了忙于等待占用cpu资源,适用于锁竞争持续时间长,线程数量多的场景,可以提高系统的整体性能
缺点:挂起等待锁涉及到线程切换和调度的开销,可能会导致性能损失
读写锁和互斥锁
读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需
要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。
一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.
- 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
- 两个线程都要写一个数据, 有线程安全问题.
- 一个线程读另外一个线程写, 也有线程安全问题.
其中 - 读加锁和读加锁之间, 不互斥.
- 写加锁和写加锁之间, 互斥.
- 读加锁和写加锁之间, 互斥.
读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行
加锁解锁.
ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进
行加锁解锁.
优点:允许多个线程同时进行读操作,提供了读并发性能,同时保证了写操作的独占性
缺点:当存在连续的写操作是,读操作可能会被阻塞,可能导致读延迟
互斥锁
互斥锁也称为排他锁,它提供了独占式访问的能力,**同一时间只允许一个线程持有锁并访问临界区。其他线程需要等待锁的释放才能继续执行。**互斥锁适用于保护对共享资源的互斥访问,避免数据竞争和并发冲突。
优点:确保了共享资源的独占性,避免了数据竞争和并发冲突。
缺点:当存在读多写少的场景时,互斥锁会导致读操作之间的串行化,降低并发性能。
公平锁和非公平锁
公平锁
公平锁是一种按照线程请求的顺序来分配锁的锁机制。**当多个线程竞争一个公平锁时,锁会按照先来后到的顺序将锁分配给等待时间最长的线程,保证了线程获取锁的公平性。**公平锁适用于需要保证线程执行顺序和公平性的场景。
优点:保证了线程获取锁的公平性,避免了饥饿现象,即某些线程一直无法获取到锁。
缺点:由于需要维护锁的申请队列和线程调度的开销,公平锁的性能通常较低。
非公平锁
非公平锁是一种不按照线程请求的顺序来分配锁的锁机制。当多个线程竞争一个非公平锁时,锁会先尝试将锁分配给当前线程,如果失败,再考虑分配给其他等待线程。非公平锁通过允许插队获取锁的方式提高了并发性能,但可能导致某些线程长时间无法获取到锁,造成不公平现象。
优点:非公平锁通过允许插队获取锁的方式提高了并发性能。
缺点:可能导致某些线程长时间无法获取到锁,造成不公平现象。
可重入锁和不可重入锁
可重入锁
可重入锁也称为递归锁,**它允许同一个线程在持有锁的情况下再次获取该锁,而不会发生死锁。**当一个线程多次获取同一把锁时,必须相应地多次释放锁才能完全释放。可重入锁适用于某个线程需要多次进入临界区的场景。
优点:允许同一个线程多次获取锁,避免了死锁的可能性;方便实现递归调用。
缺点:由于需要维护锁的重入次数,可重入锁的性能通常比不可重入锁略低。
不可重入锁
不可重入锁是一种简单的锁机制,同一个线程只能获取一次锁,如果线程尝试再次获取已经持有的锁,会导致死锁。不可重入锁适用于需要严格保证线程只能获取一次锁的场景。
优点:简单、轻量级,没有额外的重入次数计数开销。
缺点:不支持同一个线程多次获取锁,可能导致死锁;不方便实现递归调用。
CAS
CAS: 全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
- 比较 A 与 V 是否相等。(比较)
- 如果比较相等,将 B 写入 V。(交换)
- 返回操作是否成功。
CAS 伪代码
下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解CAS 的工作流程.
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
两种典型的不是 “原子性” 的代码
- check and set (if 判定然后设定值) [上面的 CAS 伪代码就是这种形式]
- read and update (i++) [之前我们讲线程安全的代码例子是这种形式]
当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。
CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式)
CAS应用
实现原子类
标准库中提供了 java.util.concurrent.atomic
包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger
类. 其中的 getAndIncrement
相当于 i++ 操作.
以上这些原子类都提供了一系列的原子性方法,可以确保对共享变量的操作具备原子性。使用原子类可以避免显式地使用锁或synchronized关键字,提供了一种高效而简便的线程安全方式。
伪代码实现
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
模拟多线程情况下,此时如何保证线程安全
假设有两个线程同时调用getAndIncrement方法
假设value初始值是0,
首先线程1将value的值赋给oldvalue,此时线程1的oldvalue的值也是0
此时线程2开始执行,也将value的值赋给oldvalue,此时线程1和线程2的oldvalue的值都是0
线程2此时进入whlie循环,调用CAS ,其中value和oldvalue的值都是0,所以将oldvalue+1的值赋给value,此时value为1,CAS方法返回true,循环结束,返回oldvalue的值为0,满足i++的特性,先复制后++
此时线程1进入while循环,此时value为1,oldvalue为0,两者不相同,返回false,进入循环体,将value的值赋给oldvalue,此时进行第二次循环,此时value的值和oldvalue的值相同,将oldvalue+1的值给value,此时value值为2,返回true,循环结束,返回oldvalue的值1,value此时的值为2
模拟完上述两个线程同时执行getAndIncrement方法后,value最终的值满足我们的要求
当两个线程并发的执行++操作时,如果不加任何限制,且++操作本身不是原子的,这意味着,如果这两个++操作如果是串行执行,则可以计算出正确结果,如果两个++的多条指令出现了穿插,这个时候就会出现线程安全问题
此时我们可以通过加锁的方式,强制两个++操作进行穿插,从而保证了线程安全
通过原子类/CAS的方式保证线程安全,借助CAS来识别当前是否出现穿插的情况,如果没有穿插,则直接进行修改,如果出现了穿插,则读取内存中最新的值,再进行修改
实现自旋锁
伪代码
public class SpinLock {
private Thread owner = null;
//此时owner表示当前是哪个对象持有这把锁,null表示解锁状态
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
CAS的ABA问题
CAS 操作的 ABA 问题指的是,在某个线程执行 CAS 操作之前,共享变量的值被改变多次,并最终返回到了原始值,因此在执行 CAS 操作时,虽然当前变量的值与期望值相等,但实际上它已经被其他线程修改过了。这种情况下,CAS 操作可能无法确保共享变量的正确性,从而发生错误。
例如
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.
接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要
- 先读取 num 的值, 记录到 oldNum 变量中.
- 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
在一般的情形下,此时没有问题,但是在一些特定的场景,此时就会出现问题
正常的过程
想象一个取款情况,我们此时卡里有100元,我们希望取50元
当我们存款时,假设这里出现了卡顿,我们再次点击,此时我们创建了两个线程,
此时线程1获取到存款100,期望更新为50,线程2获取到当前存款为100,期望更新为50
线程1扣款成功,存款被修改为50,线程2阻塞
线程2执行,发现存款为50,和之前读到的100不同,执行失败
异常的过程
还是两个线程
线程1获取到存款为100,期望更新为50,线程2获取到当前存款值为100,期望更新为50
线程1扣款成功,存款更新为50,线程2阻塞等待
在线程2执行之前,此时假设他有个朋友恰好给他转账50,此时账户余额100元
轮到线程2执行时,此时发现存款是100,和之前读到的100相同,就会再次执行扣款操作,这个时候扣款操作就被执行了两次,这就是ABA问题
解决方法
给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.
让CAS在读取旧值的同时,也要读取版本号
真正修改的时候,如果当前版本号和读到的版本号相同,则修改数据,并把版本号+1,如果当前版本号高于读到的版本号,就操作失败(认为数据已经被修改过了)
synchronized原理
基本特点
根据上述的所策略,可以总结出,synchronized具有以下特性(只考虑jdk1.8)
1.开始时是乐观锁,如果锁冲突频繁,就转化为悲观锁
2.开始时时轻量级锁的实现,如果所被持有的时间较长,就转换成重量级锁(重量级锁是基于系统的互斥锁实现的,轻量级锁部分是基于自旋锁实现的)
3.是不公平锁
4.是可重入锁
5.不是读写锁
加锁工作过程
JVM将synchronized锁分为无锁,偏向锁,轻量级锁,重量级锁状态,会根据情况依次进行升级
偏向锁
第一个尝试加锁的线程,优先进入偏向锁状态
偏向锁,不是真的加锁,而只是做了一个"标记",如果有别的线程来竞争锁,才会真的加锁,如果没有别的线程竞争,就从始至终不会真的加锁
轻量级锁
随着其他线程进入锁的竞争,偏向锁状态被消除,进入轻量级锁状态(自适应的自旋锁)
此处的轻量级锁就是通过CAS来实现
通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
如果更新成功, 则认为加锁成功
如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
自旋操作是一直让cpu空转,比较浪费cpu资源,因此此处的自旋不会一直持续进行,而是达到一定时间/次数,就不再自旋了,也就是所谓的自适应
重量级锁
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex .
执行加锁操作, 先进入内核态.
在内核态判定当前锁是否已经被占用
如果该锁没有占用, 则加锁成功, 并切换回用户态.
如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒
这个线程, 尝试重新获取锁.
其他的优化操作
锁消除
编译器会智能的判定,当前这个代码是否有必要加锁,如果你写了加锁,但是实际上没有必要加锁,就会把枷锁操作自动删除掉
锁粗化
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
第一种方式进行了很多次加锁解锁操作,第二种方式只进行了一次加锁解锁操作
JUC(java.util.concurrent)的常见的类
callable接口
Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算结果.
例如创建一个线程计算1+2+3+…+100
不是用Callable
class Result{
public int sum = 0;
public Object locker = new Object();
}
public class Demo1 {
public static void main(String[] args) throws InterruptedException {
Result result = new Result();
Thread t = new Thread(()->{
int sum = 0;
for (int i = 0; i < 100; i++) {
sum += i;
}
synchronized (result.locker){
result.sum = sum;
result.locker.notify();
//完成加法操作,唤醒主线程
}
});
t.start();
synchronized (result.locker){
while ( result.sum == 0){
//当t1线程还没有结束时,主线程wait等待t1线程执行结束,在执行主线程打印结果
result.locker.wait();
}
System.out.println(result.sum);
}
}
}
可以看到, 上述代码需要一个辅助类 Result, 还需要使用一系列的加锁和 wait notify 操作, 代码复杂, 容易出错.
使用Callable
public class Demo2 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 0; i < 100; i++) {
sum += i;
}
return sum;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t1 = new Thread(futureTask);
t1.start();
int result = futureTask.get();
System.out.println(result);
}
}
Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务, Runnable
描述的是不带返回值的任务. Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存
Callable 的返回结果. 因为 Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定. FutureTask就可以负责这个等待结果出来的工作.
理解 FutureTask
想象去吃麻辣烫. 当餐点好后, 后厨就开始做了. 同时前台会给你一张 “小票” . 这个小票就是 FutureTask.
后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没
ReentrantLock
可重入互斥锁,和synchronized定位类似,都是用来实现互斥效果,保证线程安全
用法
lock()//加锁
trylock(超时时间)//加锁,如果获取不到锁,等待一段时间之后就放弃加锁
unlock()//解锁
主要特点
可重入性:ReentrantLock 允许同一个线程多次获取同一个锁。这意味着一个线程可以在持有锁的情况下再次请求获得这个锁,而不会导致死锁。
获取锁的方式:可以使用 lock() 方法获取锁,并使用 unlock() 方法释放锁。与 synchronized不同,ReentrantLock 的获取和释放锁是显式的。可以通过 tryLock() 方法尝试非阻塞地获取锁,并根据返回值判断是否成功获取锁。
公平性控制:ReentrantLock 提供了公平锁和非公平锁两种获取锁的策略。公平锁会按照线程的请求顺序来获取锁,而非公平锁可能会导致某些线程一直获取不到锁。可以通过构造函数来指定锁的类型,默认是非公平锁。
条件变量:ReentrantLock 提供了 Condition 接口的实现,可以使用 newCondition() 方法创建条件变量,用于线程间的等待和通知。通过 await() 方法使线程等待,通过 signal() 或 signalAll()方法唤醒等待的线程。
锁的粒度控制:与 synchronized 相比,ReentrantLock 允许在代码中显式地创建多个锁对象,以实现更灵活的锁粒度控制。
原子类
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个
- AtomicBoolean
- AtomicInteger
- AtomicIntegerArray
- AtomicLong
- AtomicReference
- AtomicStampedReference
以 AtomicInteger 举例,常见方法有
addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i–;
incrementAndGet(); ++i;
getAndIncrement(); i++;
线程池
虽然创建销毁线程比创建销毁进程更轻量, 但是在频繁创建销毁线程的时候还是会比较低效.
线程池就是为了解决这个问题. 如果某个线程不再使用了, 并不是真正把线程释放, 而是放到一个 “池子” 中, 下次如果需要用到线程就直接从池子中取, 不必通过系统来创建了.
ExecutorService和Executors
ExecutorService 表示一个线程池实例.
Executors 是一个工厂类, 能够创建出几种不同风格的线程池.
ExecutorService 的 submit 方法能够向线程池中提交若干个任务.
public class Demo3 {
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(5);
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("线程池");
}
});
}
}
Executors 创建线程池的几种方式
- newFixedThreadPool: 创建固定线程数的线程池
- newCachedThreadPool: 创建线程数目动态增长的线程池
- newSingleThreadExecutor: 创建只包含单个线程的线程池.
- newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.
Executors 本质上是 ThreadPoolExecutor 类的封装.
线程池的工作流程
信号Semaphore
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
理解信号量
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)
当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.
Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用
代码示例
创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源.
acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)
创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果.
public class Demo4 {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(4);
//初始化为4,表示有4个可用资源
for (int i = 0; i < 20; i++) {
Thread t = new Thread(()->{
System.out.println("申请资源");
try {
semaphore.acquire();
System.out.println("申请到资源了");
Thread.sleep(1000);
System.out.println("释放资源了");
semaphore.release();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t.start();
}
}
}
我们可以看到,在我们启动线程后,20个线程立即打印了申请资源,但是只有四个线程获取到资源了,因为我们的信号量只有4,其他线程只能等到这四个线程释放了资源之后才能获取,在此处阻塞等待,可以看到在四个线程释放后,剩余的线程开始争夺这四个名额,在等新的四个线程结束后,其他线程在竞争,依此类推
锁本质上就是一个特殊的信号量(里面的数值,非0即1,二元信号量)
CountDownLatch
同时等待 N 个任务执行结束
好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。
- 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
- 每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.
- 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.
public class Demo5 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(5);
for (int i = 0; i < 5 ; i++) {
int temp = i;
Thread t = new Thread(()->{
System.out.println("线程 "+ temp +"执行");
countDownLatch.countDown();
});
t.start();
}
//等待所有线程结束
countDownLatch.await();
System.out.println("所有线程直接结束");
}
}
线程安全的集合类
原来的集合类, 大部分都不是线程安全的.
Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的.
因为虽然这几个集合类中的方法使用了synchronized进行加锁,但是在一些更复杂的操作中,也会出现线程安全问题
例如两个线程同时进行一个操作,先get(),然后判断get的值在进行set,此时即使加了synchronized也会出现线程安全问题,
同时这些集合类,在单线程的情况下,又可能会因为synchronized影响到执行效率
多线程环境使用ArrayList
- 自己使用同步机制(synchronized或者ReentrantLock)
- 通过
Collections.synchronizedList(new ArrayList)
;
synchronizedList是基于标准库提供的基于synchronized进行线程同步的List,他的关键操作上都带有synchronized,相当于让ArrayList像vector一样使用 - 使用后CopyOnWriteArrayList
他是一个线程安全的集合类,它通过再修改操作时创建一个新的拷贝来实现并发安全
具体来说,当对 CopyOnWriteArrayList 进行修改(如添加、修改或删除元素)时,它会创建一个原有数组的拷贝,并在这个拷贝上进行修改操作。这意味着在修改期间,读取操作仍然可以访问原始的数组,不受修改操作的影响。一旦修改完成,它将把新的拷贝设置为主要数组,以供后续的读取和写入操作使用。这种设计方式使得读操作无锁化,不会阻塞其他读操作,从而提高了并发性能。
这样做的优点是,在读多写少的场景下,性能很高,因为不需要加锁
缺点是因为每次插入数据时,要创建一份临时拷贝,所以占用内存比较多,第二是,新写入的数据不能被第一时间读取到,只能再修改完成后,再可以读取到
多线程环境使用队列
多线程情况下我们使用阻塞队列来代替普通队列
1.ArrayBlockingQueue 基于数组实现的阻塞队列
2.LinkedBlockingQueue 基于链表实现的阻塞队列
3.PriorityBlockingQueue 基于堆实现的带优先级的队列
多线程环境使用哈希表
HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用:
- Hashtable
- ConcurrentHashMap
Hashtable
只是简单的把关键方法加上了synchronized关键字,这相当于直接针对Hashtable对象本身枷锁
此时
如果多个线程访问同一个Hashtable就会直接造成锁冲突
size属性也是通过synchronized来控制同步,效率也很低
此时一旦触发扩容过程,就由当前线程完成整个扩容过程,这个过程会涉及到大量的元素拷贝,效率会非常低
相当于一把锁把整个哈希表加锁吗,此时两个线程访问Hashtable中的任意数据都会出现锁竞争
ConcurrentHashMap
相比于Hashtable做出了一系列的改进和优化,以java1.8为例
读操作没有加锁,但是使用了volatile保证从内存中读取结果,只对写操作进行加锁,加锁的方式是使用synchronized,但不是锁整个对象,而是锁哈希桶,用每个链表的头节点作为锁对象,大大降低了锁冲突的概率
此时不再是一把锁锁整个对象,而是每一把锁都只锁他所在的哈希桶,此时多线程获取不同哈希桶的元素时,就不会发生锁冲突,这就大大提高了并发程度,提升了效率
优化了扩容方式,化整为零
发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.
扩容期间, 新老数组同时存在.
后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小部分元素.
搬完最后一个元素再把老数组删掉.
这个期间, 插入只往新数组加.
这个期间, 查找需要同时查新数组和老数组
死锁
死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
举个例子
public class DeadlockExample {
private static Object lock1 = new Object();
private static Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1 acquired lock1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) {
System.out.println("Thread 1 acquired lock2");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2 acquired lock2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock1) {
System.out.println("Thread 2 acquired lock1");
}
}
});
thread1.start();
thread2.start();
}
}
在上述代码中,有两个线程 thread1 和 thread2,每个线程都试图获取两个锁:lock1 和 lock2。它们的获取顺序是相反的,即 thread1 先获取 lock1,再获取 lock2,而 thread2 先获取 lock2,再获取 lock1。
当这两个线程同时开始执行时,thread1 先获取了 lock1,然后尝试获取 lock2。同时,thread2 先获取了lock2,然后尝试获取 lock1。由于两个线程的获取顺序相反,它们相互持有对方需要的锁,产生了死锁。
在这种情况下,thread1 持有 lock1 并等待 lock2 的释放,而 thread2 持有 lock2 并等待 lock1 的释放,两个线程都无法继续执行,形成了死锁状态。
还有经典例子,哲学家就餐问题
有五位哲学家坐在圆桌周围,每个哲学家前面都有一根筷子。每两位相邻的哲学家之间有一根共享的筷子。哲学家的生活包括思考和就餐两个过程。当一个哲学家就餐时,他需要同时拿起自己左右两边的筷子才能进食。每次只能有一个哲学家拿起筷子就餐,其他哲学家必须等待。
如何避免死锁
- 互斥使用:当资源被一个线程使用时,别的线程不能使用
- 不可抢占:资源请求者不能强制从资源占有者手中夺取资源,资源只能由占有者主动释放
- 请求和保持:当资源请求者在请求其他资源时,保证对原资源的占有
- 循环等待:即存在一个等待队列:p1占有p2的资源,p2占有p3的资源,p3占有p1的资源,这样就形成了一个等待环路
当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。
其中最容易的就是破坏循环等待
最常用的一种死锁阻止技术就是锁排序. 假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号(1, 2, 3…M). N 个线程尝试获取锁的时候, 都按照固定的按编号由小到大顺序来获取锁. 这样就可以避免环路等待.