常见的锁策略
-
乐观锁 vs 悲观锁
悲观锁:认为多个线程访问同⼀个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁
乐观锁:认为多个线程访问同⼀个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据
Synchronized 初始使用乐观锁策略, 当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略
-
重量级锁 vs 轻量级锁
锁的核心特性 "原子性"
重量级锁:加锁机制重度依赖了 OS 提供了 mutex
轻量级锁: 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex
synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁
-
自旋锁(Spin Lock)
大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU,这个时候就可以使用自旋锁来处理这样的问题
自旋锁是⼀种典型的 轻量级锁 的实现方式:
自旋锁伪代码:
while (抢锁(locked) == 失败) {}
优点:没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁
缺点:如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源 (而挂起等待的时候是不消耗 CPU 的)
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的
-
公平锁 vs 非公平锁
公平锁:遵守 "先来后到",B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁
非公平锁:不遵守 "先来后到",B 和 C 都有可能获取到锁
注:公平锁和非公平锁没有好坏之分,关键还是看适用场景
-
可重入锁 vs 不可重入锁
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括 synchronized关键字锁都是可重入的
注:Linux 系统提供的 mutex 是不可重入锁
-
读写锁
读加锁和读加锁之间,不互斥
写加锁和写加锁之间,互斥
读加锁和写加锁之间,互斥
注:Synchronized 不是读写锁
-
CAS
全称:Compare and swap,字面意思:比较并交换
CAS 是怎么实现的:
java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作
unsafe 的 CAS 依赖的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg
Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性
简而言之,是因为硬件予以了支持,软件层面才能做到
CAS 有哪些应用:
(1)实现原子类:
public class test22 {
// AtomicInteger可以将++或--操作封装成原子操作
// 防止在多线程下针对同一个变量出现线程不安全问题,由于以下代码不涉及加锁操作,所以执行的效率更高
public 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++) {
// count++
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);
}
}
AtomicInteger可以将++或--操作封装成原子操作,防止在多线程下针对同一个变量出现线程不安全问题,由于以下代码不涉及加锁操作,所以执行的效率更高
(2)实现自旋锁:
自旋锁伪代码
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就⾃旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
-
CAS 的 ABA 问题:
ABA 问题:就是t1线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程
注:ABA 问题引来 BUG
例如:银行取款问题
解决方案:给要修改的值, 引入版本号,在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期
-
synchronized 原理
基本特点:
1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
3. 实现轻量级锁的时候大概率用到的自旋锁策略
4. 是⼀种不公平锁 ,是⼀种可重入锁 ,不是读写锁
加锁工作过程:
JVM 将 synchronized 锁分为 无锁→偏向锁→轻量级锁→重量级锁状态
注:不能降级
其他的优化操作:锁消除,锁粗化(将多个相同的操作放在一个锁内进行完成)
-
JUC的常见类
1. Callable 接口:Callable 是⼀个 interface,相当于把线程封装了一个 "返回值"
public class test24 { // JUC的常见类 Callable public static void main(String[] args) throws InterruptedException, ExecutionException { Callable<Integer> callable = new Callable<Integer>() { @Override public Integer call() throws Exception { int result = 0; for (int i = 1; i < 1000; i++) { result += i; } return result; } }; // 创建FutureTask对象来接受callable的返回值 FutureTask<Integer> futureTask = new FutureTask<>(callable); Thread t = new Thread(futureTask); t.start(); t.join(); System.out.println(futureTask.get()); } }
2. ReentrantLock:可重入互斥锁,和 synchronized 定位类似,都是用来实现互斥效果,保证线程安全
用法:lock():加锁, 如果获取不到锁就死等
**trylock(超时时间):**加锁,如果获取不到锁,等待一定的时间之后就放弃加锁
unlock():解锁
ReentrantLock 和 synchronized 的区别:
synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准库的一个类, 在 JVM 外实现的(基于 Java 实现)
synchronized 使用时不需要手动释放锁,ReentrantLock 使用时需要手动释放,使用起来更灵活, 但是也容易遗漏 unlock
synchronized 在申请锁失败时,会死等,ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃
synchronized 是非公平锁, ReentrantLock 默认是非公平锁,可以通过构造方法传入一个 true 开启公平锁模式
-
信号量 Semaphore
信号量:用来表示"可用资源的个数",本质上就是一个计数器
代码示例:
package thread; import java.util.concurrent.Semaphore; public class test25 { public static int count = 0; // 信号量Semaphore public static void main(String[] args) throws InterruptedException { Semaphore semaphore = new Semaphore(1); Thread t1 = new Thread(() -> { try { for (int i = 0; i < 5000; i++) { // 申请资源 semaphore.acquire(); count++; // 释放资源 semaphore.release(); } } catch (InterruptedException e) { throw new RuntimeException(e); } }); Thread t2 = new Thread(() -> { try { for (int i = 0; i < 5000; i++) { semaphore.acquire(); count++; semaphore.release(); } } catch (InterruptedException e) { throw new RuntimeException(e); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); } }
-
CountDownLatch:同时等待 N 个任务执行结束
代码示例:
package thread; import java.util.concurrent.CountDownLatch; public class test26 { // 将一个任务分成多段去完成 public static void main(String[] args) throws InterruptedException { CountDownLatch latch = new CountDownLatch(10); for (int i = 0; i < 10; i++) { int id = i; Thread t = new Thread(() -> { System.out.println("线程启动: " + id); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } System.out.println("线程结束: " + id); latch.countDown(); }); t.start(); } // 通过await等待所有线程执行完毕, 等countDown调用次数和构造方法一致时, await()才会返回 latch.await(); } }
-
线程安全的集合类
多线程环境使用 ArrayList:
-
自己使用同步机制 (synchronized 或者 ReentrantLock)
-
Collections.synchronizedList(new ArrayList);
-
使用 CopyOnWriteArrayList:
当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后往新的容器里添加元素
添加完元素之后,再将原容器的引用指向新的容器
-
多线程环境使用队列:
LinkedBlockingQueue,ArrayBlockingQueue,PriorityBlockingQueue,TransferQueue
多线程环境使用哈希表:
注:HashMap 本身不是线程安全的,在多线程环境下使用哈希表可以使用:
Hashtable:只是简单的把关键方法加上了 synchronized 关键字
ConcurrentHashMap:
-
相关面试题
-
ConcurrentHashMap的读是否要加锁,为什么?
读操作没有加锁,目的是为了进一步降低锁冲突的概率,为了保证读到刚修改的数据,搭配了 volatile 关键字
-
介绍下 ConcurrentHashMap的锁分段技术?
简单的说就是把若干个哈希桶分成一个 "段" (Segment), 针对每个段分别加锁,目的也是为了降低锁竞争的概率
当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争
-
ConcurrentHashMap在jdk1.8做了哪些优化?
取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象)
将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式,当链表较长的时候(大于等于 8 个 元素)就转换成红黑树
-
Hashtable和HashMap、ConcurrentHashMap 之间的区别?
HashMap:线程不安全,key 允许为 null
Hashtable:线程安全,使用 synchronized 锁 Hashtable 对象,效率较低,key 不允许为 null
ConcurrentHashMap:线程安全,使用 synchronized 锁每个链表头结点,锁冲突概率低, 充分利用 CAS 机制,优化了扩容方式,key 不允许为 null
-