目录
一、常见锁策略
1.读写锁 VS 普通的互斥锁
- 普通的互斥锁: 只要两个线程针对同一个对象加锁,就会产生互斥
- 读写锁:
- 加读锁: 如果代码只进行读操作,就加读锁
- 加写锁: 如果代码进行了写操作,就加写锁
- 将读写锁分开,那么 读锁和读锁之间是不互斥的 ,只有读锁和写锁,写锁和写锁之间才互斥。
2.悲观锁 VS 乐观锁
- 悲观锁:预期锁冲突的概率很高(所以就会做很多准备工作来预防锁冲突)
- 乐观锁: 预期锁冲突的概率很低
- 悲观锁做的工作更多,付出的成本更高,更低效
- 乐观锁做的工作更少,付出的成本更低,更高效
3.轻量级锁 VS 重量级锁
- 轻量级锁:做的事情更少,开销更小(一般来说,涉及到内核操作的就是重量级锁,纯用户态就是轻量级锁)
- 重量级锁: 做的事情更多,开销更大
- 通常情况下,可以认为 悲观锁都是重量级锁;乐观锁都是轻量级锁
4.挂起等待锁 VS 自旋锁
- 挂起等待锁:往往是通过内核的一些机制来实现的(较重)
- 自旋锁: 往往是通过用户态代码来实现的(较轻)
5.公平锁 VS 非公平锁
- 公平锁:多个线程在等待一把锁时,谁先来的谁先获取到, 遵守先来后到
- 非公平锁:多个线程在等待一把锁时,获取到锁的概率相同 ,不遵守先来后到
- 此处的公平指的是 : 遵守先来后到
6.可重入锁 VS 不可重入锁
- 可重入锁:一个线程针对一把锁,连续加锁两次,不会出现死锁
外部方法获取到锁对象,内部再获取同一把锁时,不会真的去拿锁对象,而是将count+1,当执行完内部方法时,count--,然后执行完外部方法时,count变为0,真正释放该锁对象
- 不可重入锁:一个线程针对一把锁,连续加锁两次,出现死锁
外部方法获取到锁对象,进入内部方法,内部方法需要等外部方法释放锁对象,然后获取到该锁对象才能执行下去,而锁对象被外部方法占据着,而外部方法需要内部执行完,才能够释放锁对象,出现死锁
二、CAS
CAS要做的事情就是: 拿着寄存器/某个内存的值 和 另一个寄存器/内存的值进行比较,如果值相等,就将两个的值交换
CPU提供了一个单独的CAS指令,通过这一条指令,就能完成上述过程,所以上述操作是原子的,线程安全
那么基于CAS能实现什么呢?
- 基于CAS实现原子类
-
java标准库里提供了一组原子类,针对一些常用的类型(int / long …)进行了封装,可以基于CAS的方式进行修改其值,并且线程安全
例子: 使用AtomicInteger类对象自增(线程安全)AtomicInteger num = new AtomicInteger(0); Thread t1 = new Thread(() -> { for (int i = 0; i < 50; i++) { // 相当于num++,且是原子的 num.getAndIncrement(); } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50; i++) { // 相当于num++,且是原子的 num.getAndIncrement(); } }); t1.start();// 启动 t1线程 t2.start();// 启动 t2线程 t1.join(); t2.join(); System.out.println(num);// 输出100
getAndIncrement():
-
基于CAS实现自旋锁
当该锁对象被其他线程占用时,当前线程就无法获取到锁对象,一直自旋等待(忙等),当前锁对象一为null时,该线程立即就能占用该锁对象,所以自旋锁不适用于锁冲突激烈的场景。 -
CAS中的ABA问题
CAS在比较时,当两次比较的值相等,则认为是没发生改变,但是实际上却不一定,有以下两种可能:
1.一直是 A
2.A —> B —> A例如下面的情况,就会出现bug
引入 ABA 问题: 假设在第一次取完款的一瞬间,账户里收到转账50元
两个线程执行如下:
当第二次进行CAS时,余额从 100 —> 50 —> 100,而系统却确认为余额没有变过,又进行了一次扣钱,导致多扣了一次钱
解决问题: 引入一个 " 版本号 ",括号里为版本号,每次针对余额修改时, 版本号+1
100(1) —> 50(2) —> 100(3),当版本号不一致时,不扣钱
三、synchronized的优化手段
-
锁膨胀/锁升级
对于偏向锁:- 并不是真正的加锁,而是做了个标记,如果没有其他线程来竞争这个锁,就不真正加锁,减少了加锁解锁的开销
- 如果有其他线程来竞争,就转为自旋锁(轻量级锁),真正加锁
-
锁粗化
此处的粗细指的是 " 锁的粒度 "(锁的涉及范围)- 如果锁的粒度比较细,那么多个线程之间的并发性更高
- 如果锁的粒度比较粗,那么加锁解锁的开销更小
- 锁消除
有些代码明明不需要加锁,你加锁了,编译器就会直接把锁去掉。例如单线程中使用 StringBuffer
四、JUC的常见类
ReentrantLock: 可重入锁
- lock(): 加锁,如果获取不到锁就死等。
- trylock(超时时间): 加锁,如果获取不到锁,等待一定的时间之后就放弃加锁。
- unlock(): 解锁
- 和synchronized的区别:
- synchronized是一个关键字;ReentrantLock是一个类
- synchronized不需要手动释放类;ReentrantLock必须手动释放
- synchronized如果竞争锁对象失败,就会阻塞等待;ReentrantLock 除了阻塞等待,还能trylock,失败了直接返回
- synchronized是非公平锁;ReentrantLock可以指定公平/非公平
- 基于synchronized衍生的等待机制是 wait - notify;基于ReentrantLock衍生的等待机制是 Condition类
Callable 接口: 描述一个任务,方便返回计算结果,Runnable不方便返回结果。
例如: 创建一个线程,让该线程实现 1+2+3+…+1000,并返回结果
public static void main(String[] args) {
// Callable描述一个任务
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的任务,需要使用到一个中间类 FutureTask
// 凭 FutureTask的对象 来取任务的返回结果
FutureTask<Integer> task = new FutureTask<>(callable);
Thread t = new Thread(task);
t.start();
// 如果还没算出结果,就会阻塞,算出来了就直接返回
try {
System.out.println(task.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
信号量(Semaphore): 信号量,用来表示 “可用资源的个数”,本质上就是一个计数器。" 锁 " 就是一个二元信号量。
例如停车场的车位:每次有车开进去,车位-1;有车开出来,车位+1
public static void main(String[] args) throws InterruptedException {
// 可用资源有 4 个
Semaphore semaphore = new Semaphore(4);
// 连续 5个申请资源,第五个申请就会阻塞,直到有人释放资源
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
// 阻塞,直到有其他线程释放资源
semaphore.acquire();
System.out.println("申请成功");
// 释放资源
// semaphore.release();
}
CountDownLatch: 同时等待 N 个任务执行结束
就像跑步比赛中的终点线,等所有选手跑完后,再公布结果
- 构造 CountDownLatch 实例,初始化 10 表示有 10 个任务需要完成。
- 每个线程调用 latch.countDown(),表示该线程执行完了
- 主线程中使用 latch.await(),阻塞等待所有任务执行完毕。
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(6);
for(int i = 0; i < 6; i++){
Thread t = new Thread(() -> {
try {
Thread.sleep(3000);
System.out.println(Thread.currentThread().getName()+"到达终点!");
// 通知主线程该线程执行完了
latch.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t.start();
}
// 等待所有线程执行完,就执行下面的语句
latch.await();
System.out.println("所有选手到达终点!");
}
五、多线程下使用哈希表
HashMap 本身不是线程安全的,在多线程环境下使用哈希表可以使用 Hashtable 、ConcurrentHashMap
- Hashtable: 在关键方法上加了 synchronized(对this加锁,即给整个Hashtable加锁),锁冲突的概率很大,效率会很低
- ConcurrentHashMap: 针对每个链表的头结点加锁
- 减少了锁冲突,针对每个链表的头结点加锁
- 只是针对写操作加锁,读操作没加锁,只是使用了 volatile
- 更广泛的使用了CAS,进一步提高效率
- 当触发扩容操作时,同时会维护一个新的HashMap,一点点将旧的数据搬运到新的上面去,当其他线程读的时候,读旧的表,插入时,插入到新表。