常见的锁策略
这里讨论的锁策略,不仅仅局限于 Java
乐观锁 vs 悲观锁
锁冲突: 两个线程尝试获取一把锁,一个线程能获取成功,另一个线程阻塞等待。
乐观锁: 预该场景中,不太会出现锁冲突的情况。后续做的工作会更少。
悲观锁: 预测该场景,非常容易出现锁冲突。后续做的工作会更多。
重量级锁 vs 轻量级锁
重量级锁: 加锁的开销是比较大的(花的时间多,占用系统资源多)
轻量级锁: 加锁开销比较小的,(花的时间少,占用系统资源少)
一个悲观锁,很可能是重量级锁(不绝对)。一个乐观锁,也很可能是轻量级锁(不绝对)
悲观乐观,是在加锁之前,对锁冲突概率的预测,决定工作的多少。重量轻量,是在加锁之后,考量实际的锁的开销。正是因为这样的概念存在重合,针对一个具体的锁,可能把它叫做乐观锁,也可能叫做轻量级锁。
自旋锁(Spin Lock)vs 挂起等待锁
自旋锁:是轻量级锁的一种典型实现
- 在用户态下,通过自旋的方式**(while 循环)**实现类似于加锁的效果的
- 这种锁,会消耗一定的 cpu 资源,但是可以做到最快速度拿到锁。
挂起等待锁:是重量级锁的一种典型实现
- 通过内核态,借助系统提供的锁机制。
- 当出现锁冲突的时候,使冲突的线程出现挂起**(阻塞等待)**。挂起等待不会消耗CPU
- 这种方式,消耗的 cpu 资源是更少的。也就无法保证第一时间拿到锁。
读写锁 VS 互斥锁
读写锁:把读操作加锁和写操作加锁分开了
一个事实: 多线程同时去读同一个变量,不涉及到线程安全问题。
如果两个线程, 一个线程读加锁,另一个线程也是读加锁,不会产生锁竞争。(并发执行效率更高了)
如果两个线程,一个线程写加锁,另一个线程也是写加锁,会产生锁竞争。
如果两个线程, 一个线程写加锁,另一个线程读加锁,也会产生锁竞争。
实际开发中,读操作的频率,往往比写操作,高很多。Java 标准库里,也提供了现成的读写锁。ReentrantReadWriteLock 。
- ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行
加锁解锁。 - ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进
行加锁解锁。
互斥锁:Synchronized 这种只有单纯的加锁解锁两个操作。
公平锁 vs 非公平锁
公平锁:是遵守先来后到的锁。B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁: 不遵守 “先来后到”. B 和 C 都有可能获取到锁 。
非公平锁看起来是概率均等,但是实际上是不公平.(每个线程阻塞时间是不一样的)。
操作系统自带的锁 (pthread mutex) 属于是非公平锁。要想实现公平锁,就需要有一些额外的数据结构来支持。比如需要有办法记录每个线程的阻塞等待时间。
可重入锁 vs 不可重入锁
如果一个线程,针对一把锁,连续加锁两次,会出现死锁,就是不可重入锁; 不会出现死锁,就是可重入锁。
public synchronized void increase(){
synchronized(locker){
count++;
}
}
1.调用方法,先针对 this 加锁. 此时假设加锁成功了
2.接下来往下执行到 代码块 中的 synchronized。此时,还是针对 this 来进行加锁。
此时就会产生锁竞争.当前 this 对象已经处于加锁状态了。此时,该线程就会阻塞,一直阻塞到锁被释放,才能有机会拿到锁。
此时,由于 this 的锁没法释放。这个代码就卡在这里了,因此这个线程就僵住了。此时就产生了死锁。
这里的关键在于,两次加锁,都是“同一个线程"。第二次尝试加锁的时候,该线程已经有了这个锁的权限了, 这个时候不应该加锁失败的,不应该阻塞等待的。
不可重入锁:这把锁不会保存,是哪个线程对它加的锁。只要它当前处于加锁状态之后,收到了"加锁”这样的请求 就会拒绝当前加锁。而不管当下的线程是哪个。就会产生死锁。
可重入锁:是会让这个锁保存,是哪个线程加上的锁。后续收到加请求之后,就会先对比一下,看看加锁的线程是不是当前持有自己这把锁的线程,这个时候就可以灵活判定了。
synchronized本身是一个可重入锁, 实际上不会产生上述的死锁情况。
死锁
死锁概念
死锁:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。多个线程被无限期阻塞,导致线程不可能正常终止。
死锁的三种典型情况:
- 一个线程,一把锁,但是是不可重入锁.该线程针对这个锁连续加锁两次,就会出现死锁
- 两个线程,两把锁.这两个线程先分别获取到一把锁,然后再同时尝试获取对方的锁。
- N 个线程 M 把锁,哲学家就餐问题。
两个线程两把锁问题:
就相当于一个在疫情时期的一个段子。健康码坏了,程序员要进去修,但是程序员不能出示健康码不能进去修,要想有健康码就得修好了才能出示。
public class ThreadDemo {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2){
System.out.println("t1两把锁加锁成功");
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker1){
System.out.println("t2两把锁加锁成功");
}
}
});
t1.start();
t2.start();
}
}
上面代码就会出现死锁问题。每个线程都卡在了第二次加锁的过程。
如果是一个服务器程序,出现死锁。死锁的线程就僵住了,就无法继续工作了, 会对程序造成严重的影响。
N 个线程 M 把锁,哲学家就餐问题:
每个哲学家,主要要做两件事:
1.思考人生.会放下筷子
2.吃面.会拿起左手和右手的筷子,再去夹面条吃。
其他设定:
1.每个哲学家,啥时候思考人生,啥时候吃面条,都不确定的
2.每个哲学家一旦想吃面条了,就会非常固执的完成吃面条的操作。如果此时,他的筷子被别人使用了,就会阻塞等待,而且等待过程中不会放下手里已经拿着的筷子。
基于上述的模型设定,绝大部分情况下,这些哲学家都是可以很好的工作的。但是,如果出现了极端情况,就会出现死锁。比如:同一时刻,五个哲学家都想吃面,并且同时伸出 左手 拿起左边的筷子。再尝试伸右手拿右边的筷子。此时就会哪个哲学家都不会吃上面条了,这里五个哲学家无根筷子相当于5个线程5把锁。
避免死锁
死锁产生的必要条件:
- 互斥使用:一个线程获取到一把锁之后,别的线程不能获取到这个锁
- 实际使用的锁,一般都是互斥的(锁的基本特性)
- 不可抢占锁: 只能是被持有者主动释放,而不能是被其他线程直接抢走
- 也是锁的基本的特性
- 请求和保持: 一个线程去尝试获取多把锁,在请求获取第二把锁的过程中,会保持对第一把锁的获取状态。
- 取决于代码结构(很可能会影响到需求)
- 循环等待: t1 尝试获取 locker2,需要 等待 t2 执行完,释放 locker2。t2 尝试获取 locker1,需要 等待 t1 执行完,释放 locker1。
- 取决于代码结构
缺一不可,只要能够破坏其中的任意一个条件,都可以避免出现死锁。
解决死锁问题的最关键要点:破除循环等待。
破除循环等待:针对锁进行编号。并且规定加锁的顺序。比如,约定,每个线程如果要获取多把锁,必须先获取 编号小的锁,后获取编号大的锁。只要所有线程加锁的顺序,都严格遵守上述顺序,就一定不会出现环等待。
针对上面死锁代码进行加锁编号,来解决死锁问题:
public class ThreadDemo {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2){
System.out.println("t1两把锁加锁成功");
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2){
System.out.println("t2两把锁加锁成功");
}
}
});
t1.start();
t2.start();
}
}
Synchronized 原理
Synchronized的锁策略
synchronized 具体是采用了哪些锁策略呢?
- 1.synchronized 既是悲观锁, 也是乐观锁
- 2.synchronized 既是重量级锁,也是轻量级锁.(自适应)
- 3.synchronized 重量级锁部分是基于系统的互斥锁实现的; 轻量级锁部分是基于自旋锁实现的
- 4.synchronized 是非公平锁(不会遵守先来后到 锁释放之后,哪个线程拿到锁,各凭本事)
- 5.synchronized 是可重入锁.(内部会记录哪个线程拿到了锁,记录引用计数)
- 6.synchronized 不是读写锁,是互斥锁。
synchronized 加锁过程
代码中写了一个 synchronized 之后,这里可能会产生一系列的“自适应的过程”,锁升级(锁膨胀)。
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
偏向锁:不是真的加锁,而只是做了一个”标记“。如果有别的线程来竞争锁了,才会真的加锁。如果没有别的线程竞争,就自始至终都不会真的加锁了。**加锁本身,有一定开销。能不加,就不加。非得是有人来竞争了,才会真的加锁。**偏向锁在没有其他人竞争的时候,就仅仅是一个简单的标记(非常轻量)。一旦有别的线程尝试进行加锁,就会立即把偏向锁,升级成真正加锁的状态,让别人只能阻塞等待。
轻量级锁:sychronized 通过自旋锁的方式来实现轻量级锁。我这边把锁占据了,另一个线程就会按照自旋的方式,来反复查询当前的锁的状态是不是被释放了。但是,后续,如果竞争这把锁的线程越来越多了(锁冲突更激烈了),从轻量级锁,升级成重量级锁。
轻量级锁的操作是比较消耗 CPU 的。 如果能够比较快速的拿到锁,多消耗点 CPU 也不亏。但是,随着竞争更加激烈,即使前一个线程释放锁 ,也不一定能拿到锁,啥时候能拿到,时间可能会比较久了。
synchronized 的优化操作
锁消除:编译器,会智能的判定,当前这个代码,是否有必要加锁。如果,你写了加锁,但是实际上没有必要加锁,就会把加锁操作自动删除掉。
锁粗化:关于"锁的粒度",如果加锁操作里包含的实际要执行的代码越多,就认为锁的粒度越粗。一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化 。
有的时候,希望锁的粒度小比较好,并发程度更高。有的时候,也希望锁的粒度大比较好,因为加锁解锁本身也有开销。
CAS
CAS的概念
CAS: 全称Compare and swap,字面意思:”比较并交换“。能够比较和交换 某个寄存器 中的值 和 内存 中的值,看是否相等。如果相等,则把另外一个寄存器中的值和内存进行交换。
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
//此处,严格的说,是把 address 内存的值,和 swapValue 寄存器里的值, 进行交换。
//但是一般我们重点关注的是内存中的值。
//寄存器往往作为保存临时数据的方式,这里的值是啥,很多时候就忽略了。
return true;
}
return false;
}
address:内存地址
expectValue, swapValue:寄存器中的值
上面一段逻辑,是通过一条 cpu 指令完成的(原子的)。这个就给我们编写线程安全代码,打开了新世界的大门。基于 CAS 又能衍生出一套"无锁编程“。但是CAS 的使用范围具有一定局限性的。
CAS的实现是:硬件予以了支持,软件层面才能做到 。
CAS的应用
1. 实现原子类
比如,多线程针对一个 count 变量进行 ++,在java 标准库中基于CAS,已经提供了一组原子类。
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的。AtomicBoolean,AtomicInteger,AtomicIntegerArray,AtomicLong,AtomicReference,AtomicStampedReference
以 AtomicInteger 举例,常见方法有 :
-
addAndGet(int delta); 相当于 i += delta;
-
getAndIncrement 相当于 i++ 操作。
-
incrementAndGet 相当于 ++i 操作。
-
getAndDecrement 相当于 i-- 操作。
-
decrementAndGet 相当于 --i 操作。
public class ThreadDemo26 {
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
//Java 不像 C++ Python 能支持运算符重载,这里必须通过调用方法的方式来完成自增
count.getAndIncrement();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count.get());
}
}
上述的原子类,就是基于 CAS 来实现的。
伪代码实现:
class AtomicInteger {
private int value;//很可能有个别的线程穿插在这俩代码之间,把 value 给改.
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
oldValue:也可以是寄存器中的值,由于以往学过的 C/Java 里头,并没有啥办法定义一个“寄存器”的变量。
这里的比较value和oldValue相等,其实就是在检查当前 value 是不是变了。是不是被别的线程穿插进来做出修改了。进一步就发现了当前的 ++ 操作不是一气呵成的原子操作了。一旦发现出现其他线程穿插的情况,立即重新读取内存的值准备下一次尝试。
加锁保证线程安全: 通过锁,强制避免出现穿插。
原子类/CAS 保证线程安全: 借助 CAS 来识别当前是否出现其他线程"穿插”的情况。如果没穿插,此时直接修改 就是安全的。如果出现穿插了,就重新读取内存中的最新的值,再次尝试修改。
2. 实现自旋锁
基于 CAS 实现更灵活的锁, 获取到更多的控制权。
自旋锁伪代码:
public class SpinLock {
private Thread owner = null;
//此处使用 owner 表示当前是哪个线程持有的这把锁.null 解锁状态
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
//Thread.currentThread()获取当前线程引用
//哪个线程调用 lock,这里得到的结果就是哪个线程的引用
}
//当该锁已经处于加锁状态,这里就会返回 false,
//CAS 不会进行实际的交换操作.接下来循环条件成立,继续进入下一轮循环
}
public void unlock (){
this.owner = null;
}
}
CAS 的 ABA 问题
CAS 关键要点,是比较 寄存器1 和 内存 的值。通过这里的是否相等,来判定 内存的值 是否发生了改变。如果内存的值变了,存在其他线程进行了修改。如果内存的值没变,没有别的线程修改,接下来进行的修改就是安全的。
ABA 的问题: 另一个线程,把 变量的值从 A -> B,又从 B -> A。此时本线程区分不了,这个值是始终没变,还是出现变化又回来了的情况。
大部分情况下,就算是出现 ABA 问题,也没啥太大影响。但是如果遇到一些极端的场景可能会出现问题:
账户 100 ,希望取款50,还剩50。假设出现极端问题:按第一下取款的时候,卡了一下, 我又按了一下。产生了两个“取款”请求,ATM 使用两个线程来处理这俩请求。假设按照 CAS 的方式进行取款,每个线程这样操作:
- 读取账户余额.放到变量 M 中。
- 使用 CAS 判定当前实际余额是否还是 M。如果是,就把实际余额修改成 M-50。如果不是,就放弃当前操作(操作失败)。
上面这个ABA问题属于非常巧合的情况,取款的时候卡了 + 碰巧这个时候有人给你转了50
虽然上述操作,概率比较小,也需要去考虑。ABA问题的解决方式:
ABA 问题,CAS 基本的思路是 没有问题 的,但是主要是修改操作能够进行反复改变,就容易让咱们 cas 的判定失效。CAS 判定的是“值相同”,实际上期望的是“值没有变化过"。比如约定,值只能单向变化(比如只能增长,不能减小)。虽余额不能只增张不减少,但是衡量余额是否改变的标准可以是看版本号。给账户余额安排一个 其他属性版本号(只增加,不减少)。使用 CAS 判定版本号,如果版本号相同,则数据一定是没有修改过的,如果数据修改过,版本号一定要增加。
JUC(java.util.concurrent) 的常见类
juc中的类是为了并发编程准备的。java官方文档
Callable interface
也是一种创建线程的方式
Runnable 能表示一个任务 (run 方法),返回 void
Callable 也能表示一个任务 (call 方法),返回一个具体的值,类型可以通过泛型参数来指定(Object)。
如果进行多线程操作,如果你只是关心多线程执行的过程,使用 Runnable 即可如。果是关心多线程的计算结果,使用 Callable 更合适。
通过多线程的方式计算一个公式,比如创建一个线程,让这个线程计算 1 + 2 + 3 +…+ 1000,使用Callable解决更合适。
- 使用 Callable 不能直接作为 Thread 的构造方法参数
- 借助FutureTask 来作为Thread的构造方法参数
public class ThreadDemo {
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 = 1; i <= 1000; i++) {
sum += i;
}
return sum;
}
};
//使用 Callable 不能直接作为 Thread 的构造方法参数
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
//获取 call 方法的返结果get ,类似于join 一样, 如果 call 方法没执行完,会阻塞等待
Integer result = futureTask.get();
System.out.println(result);
}
}
ReentrantLock
可重入锁,这个锁 没有 synchronized 那么常用,但是也是一个可选的加锁的组件。这个锁在使用上更接近于 C++ 里的锁。
- lock() 加锁
- unlock() 解锁
分开操作,就容易出现unlock 调用不到的情况,容易遗漏。比如,中间 return / 抛出异常了。ReentrantLock 具有一些特点,是 synchronized 不具备的功能(优势):
-
提供了一个 tryLock 方法进行加锁
- 对于 lock 操作,如果加锁不成功,就会阻塞等待(死等)
- 对于 tryLock,如果加锁失败,直接返回 false/也可以设定等待时间。
- tryLock 给加锁操作提供了更多的可操作空间。
-
ReentrantLock 有两种模式。可以工作在公平锁状态下,也可以工作在非公平锁的状态下。
- 构造方法中通过参数设定的 公平/非公平模式。
-
ReentrantLock 也有等待通知机制,搭配 Condition 这样的类来完成这里的等待通知。要比 wait notify 功能更强
虽然ReentrantLock有上述这些优点,但是 ReentrantLock 劣势也很明显(比较致命),unlock 容易遗漏使用 finally 来执行 unlock。
synchronized 锁对象是任意对象。ReentrantLock 锁对象就是自己本身。如果你多个线程针对不同的 ReentrantLock 调用 lock 方法,此时是不会产生锁竞争的。实际开发中,进行多线程开发,用到锁还是首选 synchronized。
原子类
原子类的应用场景:
计数请求:播放量,点赞量,投币量,转发量,收藏量。同一个视频,有很多人都在同时的播放/点赞/收藏
统计效果:
统计出现错误的请求数目。—> 使用原子类,记录出错的请求的数目。—> 另外写一个监控服务器,获取到线上服务器的这些错误计数,并且以曲线图的方式绘制到页面上。
某次发布程序之后,发现,突然这里的错误数大幅度上升,说明你这个新版本代码大概率存在 bug。
统计收到的请求总数(衡量服务器的压力)。统计每个请求的响应时间 => 平均的响应时间(衡量服务器的运行效率)。
最低 1% 的响应时间是多少(1% low 帧)。线上服务器通过这些统计内容,进行简单计数 =>实现监控服务器,获取/统计/展示/报警。
信号量 Semaphore
Semaphore 是并发编程中的一个重要的概念/组件。准确来说,Semaphore 是一个计数器(变量),描述了**"可用资源"的个数**。描述的是,当前这个线程,是否**“有临界资源可以用“**。
- P 操作:申请了一个可用资源 - 1。accquire (申请)
- V 操作:释放了一个可用资源 +1。release (释放)
当计数器数值为 0 的时候,继续进行 P 操作,就会阻塞等待,一直等待到其他线程执行了 V 操作,释放了一个空闲
资源为止。锁,本质上是一个特殊的信号量(里面的数值,非 0 即 1二元信号量)。信号量要比锁更广义,不仅仅可以描述一个资源,还可以描述 N 个资源。虽然概念上更广泛,实际开发中,还是锁更多一些(二元信号量的场景是更常见的)。
//信号量
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
// 构造方法中, 就可以用来指定计数器的初始值.
Semaphore semaphore = new Semaphore(4);
semaphore.acquire();//计数器-1
System.out.println("执行p操作");
semaphore.acquire();//计数器-1
System.out.println("执行p操作");
semaphore.acquire();//计数器-1
System.out.println("执行p操作");
semaphore.acquire();//计数器-1
System.out.println("执行p操作");
semaphore.acquire();//计数器-1
System.out.println("执行p操作");
}
}
CountDownLatch
针对特定场景一个组件。同时等待 N 个任务执行结束。
下载某个东西:有的时候,下载一个比较大的文件,比较慢(慢不是因为你家里的网速限制,往往是人家服务器这边的限制)。有一些多线程下载器”,把一个大的文件,拆分成多个小的部分,使用多个线程分别下载。每个线程负责下载一部分,每个线程分别是一个网络连接。就会大幅度提高下载速度。假设,分成 10个线程,10个部分来下载。 10个部分都下载完了,整体才算完成。
//CountDownLatch
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
// 构造方法中, 指定创建几个任务.
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
int id = i;
Thread t = new Thread(()-> {
System.out.println("线程" + id + "开始工作");
try {
// 使用 sleep 代指某些耗时操作, 比如下载.
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程" + id + "结束工作");
// 每个任务执行结束这里, 调用一下方法
// 把 10 个线程想象成短跑比赛的 10 个运动员. countDown 就是运动员撞线了.
countDownLatch.countDown();
});
t.start();
}
// 主线程如何知道上述所有的任务都完成了呢??
// 难道要在主线程中调用 10 次 join 嘛?
// 万一要是任务结束, 但是线程不需要结束, join 不就也不行了嘛。
// 主线程中可以使用 countDownLatch 负责等待任务结束.
// a => all 等待所有任务结束. 当调用 countDown 次数 < 初始设置的次数, await 就会阻塞.
countDownLatch.await();
System.out.println("多个线程的所有任务都执行完毕了!!");
}
}
线程安全的集合类
多个线程同时操作这个集合类,不会会产生问题就是线程安全的。
Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的.
在关键的方法中,使用了 synchronized。Vector 和 HashTable 属于是 Java 上古时期,搞出来的集合类。加了锁,不一定就线程安全。不加锁也不一定就线程不安全 => 要具体问题具体分析。
虽然 get 和 set 方法加了 synchronized ,但是如果不能正确使用,也可能会出现线程安全问题:
- 如果是多个线程,并发执行 set 操作,由于 synchronized 限制,是线程安全。
- 如果多个线程进行一些更复杂的操作,比如判定 get 的值是 xxx,再进行 set,可能会线程不安全。
即使把这里的 get 和 set 分别进行加锁。如果不能正确的使用,也可能产生线程安全问题。考虑到实际的逻辑中,哪些代码是要作为一个整体的(原子的)。
线程安全下使用ArrayList
Collections.synchronizedList(new ArrayList);
ArrayList 本身没有使用 synchronized。但是你又不想自己加锁,就可以使用上面这个东西,相当于让 ArrayList 像 Vector 一样工作。(很少会用)
使用 CopyOnWriteArrayList 写时复制
多个线程同时修改同一个变量,如果多个线程修改不同变量,就会安全了。
如果多线程去读取,本身就不会有任何线程安全问。一旦有线程修改,就会把自身复制一份。尤其是修改比较耗时的话,其他线程还是旧的数据上读取。一旦修改完成,使用新的 ArrayList 替换目的 ArrayList (本质上就是一个引用的重新赋值速度极快,并且又是原子的)
这个过程中,没有引入任何的加锁操作。使用了创建副本 => 修改副本 => 使用副本替换。
线程安全下使用HashMap
ConcurrentHashMap 线程安全的 hash 表。
- HashTable 是在方法上直接加上 synchronized,就相当于针对 this 加锁。
如果两个修改操作,是针对两个不同的链表进行修改,不会存在线程安全问题。既然这里没有线程安全问题,但是锁又不能完全不加,因为两个修改可能在同一个链表中同一个位置进行插入操作。
为了解决上面的问题:给每个链表都加一把锁。
一个hash表上面的链表个数这么多,两个线程正好在同时操作同一个链表的概率本身就是比较低的,整体锁的开销就大大降低了。由于 synchronized 随便拿个对象都可以用来加锁,就可以简单的使用每个链表的头结点,作为锁对象即可。
ConcurrentHashMap 改进:
- [核心] 减小了锁的粒度,每个链表有一把锁。大部分情况下都不会涉及到锁冲突。
- 广泛使用了 CAS 操作(比如size++)
- 写操作进行了加锁(链表级),读操作,不加锁了。
- 针对扩容操作进行了优化,浙进式扩容。
HashTable 一旦触发扩容, 就会立即的一口气的完成所有元素的搬运,这个过程相当耗时。大部分请求都很顺畅,突然某个请求就卡了比较久。化整为零,当需要进行扩容的时候,会创建出另一个更大的数组,然后把旧的数组上的数据逐渐的往新的数组上搬运。会出现一段时间,旧数组和新数组同时存在。
- 新增元素,往新数组上插入。
- 删除元素,把旧数组的元素给删掉即可。
- 查找元素,新数组旧数组都得查找。
- 修改元素,统一把这个元素给搞到新数组上。
与此同时,每个操作都会触发一定程度搬运。每次搬运一点,就可以保证整体的时间不是很长。积少成多之后,逐渐完成搬运了,也就可以把之前的旧数组彻底销毁了。
介绍下 ConcurrentHashMap的锁分段技术?
Java 8 之前,ConcurrentHashMap 是使用分段锁,从 Java 8 开始,就是每个链表自己一把锁了。