文章目录
- 前言
- 一、常见锁策略(八股文)
- 1.1乐观锁和悲观锁
- 1.2轻量级锁和重量级锁
- 1.3自旋锁和挂起等待锁
- 1.4普通互斥锁和读写锁
- 1.5公平锁和非公平锁
- 1.6可重入锁和不可重入锁
- 1.7总结
- 二、synchronized内部原理
- 三、CAS
- 四、JUC(java.util.concurrent) 的常见类
- 4.1Callable 接⼝
- 4.2ReentrantLock
- 4.3信号量 Semaphore
- 4.4CountDownLatch
- 4.5线程安全的集合类
前言
多线程编程中的锁策略对于确保数据一致性和线程安全至关重要。 本文将介绍悲观锁、乐观锁以及轻量级锁等常见锁策略内容~
一、常见锁策略(八股文)
1.1乐观锁和悲观锁
乐观锁:在加锁之前,预估当前出现冲突锁的概率不大,因此在进行加锁的时候就不会做太多的工作,加锁过程做的事情比较少,加锁速度可能就更快,但是更容易引入一些其他的问题(可能消耗更多CPU资源)。
悲观锁:在加锁之前,预估当前出现冲突锁的概率比较大,因此在进行加锁的时候就会做更多的工作,加锁过程做的事情更多,加锁速度可能更慢,但是整个过程中不容易出现其他问题。
1.2轻量级锁和重量级锁
轻量级锁:加锁的开销小,加锁的速度更快=>一般是乐观锁。
重量级锁:加锁的开销更大,加锁的速度更慢=>一般是悲观锁。
轻量重量,加锁之后,对结果的评价;
悲观乐观,加锁之前,对未发生的事情进行的预估;
整体来说,是两种角度,描述的是同一个事情。
1.3自旋锁和挂起等待锁
自旋锁:是轻量级锁的一种典型实现,进行加锁的时候,搭配一个while循环,如果加锁成功,自然循环结束;如果加锁不成功,不是阻塞放弃CPU,而是进行下一次循环,再次尝试获取到锁。
• 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, ⼀旦锁被释放, 就能第⼀时间获取到锁.
• 缺点: 如果锁被其他线程持有的时间⽐较久, 那么就会持续的消耗 CPU 资源. (⽽挂起等待的时候是不消耗 CPU 的).
挂起等待锁:是重量级锁的一种典型实现,进行挂起等待的时候,需要内核调度器介入,这一块要完成的操作就多了,真正获取到锁要花的时间就更多一些了。(适用于锁冲突激烈的情况)
1.4普通互斥锁和读写锁
普通互斥锁:类似于synchronized,操作涉及加锁和解锁。 读写锁:在执⾏加锁操作时需要额外表明读写意图,复数读者之间并不互斥,⽽写者则要求与任何⼈互斥。
多线程之间,数据的读取⽅之间不会产⽣线程安全问题,但数据的写⼊⽅互相之间以及和读者之间都
需要进⾏互斥。如果两种场景下都⽤同⼀个锁,就会产⽣极⼤的性能损耗。所以读写锁因此⽽产⽣。
⼀个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据。
• 两个线程都只是读⼀个数据, 此时并没有线程安全问题.直接并发的读取即可.
• 两个线程都要写⼀个数据, 有线程安全问题.
• ⼀个线程读另外⼀个线程写, 也有线程安全问题.
读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
• ReentrantReadWriteLock.ReadLock 类表⽰⼀个读锁. 这个对象提供了 lock / unlock ⽅法进⾏加锁解锁.
• ReentrantReadWriteLock.WriteLock 类表⽰⼀个写锁. 这个对象也提供了 lock / unlock⽅法进⾏加锁解锁.
其中,
读加锁和读加锁之间,不会出现锁冲突(不会阻塞)
写加锁和写加锁之间,会出现锁冲突(会阻塞)
读加锁和写加锁之间,会出现锁冲突(会阻塞)
读写锁最主要⽤在 “频繁读, 不频繁写” 的场景中
1.5公平锁和非公平锁
公平锁:指锁按照请求的顺序来分配,先到先得的原则,保证每个线程都有公平竞争的机会。公平锁会降低系统的吞吐量,但能够避免某些线程被永久性地阻塞。
非公平锁:锁不考虑请求的顺序,可能会出现某些线程一直获取到锁而其他线程一直获取不到锁的情况,存在饥饿现象。非公平锁可以提高系统的吞吐量,但可能会导致某些线程无法获取到锁。
1.6可重入锁和不可重入锁
可重入锁:一个线程针对这一把锁,连续加锁两次,不会死锁。可重入锁中需要记录持有锁的线程是谁,加锁的次数的计数。
不可重入锁:一个线程针对这一把锁,连续加锁两次,会死锁。
1.7总结
synchronized具有自适应能力。
如果当前锁冲突的激烈程度不大,就处于乐观锁/轻量级锁/自旋锁;如果当前锁冲突很大,就处于悲观锁/重量级锁/挂起等待锁。
一般来说,无脑使用synchronized不会有问题,并且很高效。
二、synchronized内部原理
当线程执行到synchronized的时候,如果这个对象当前处于未加锁的状态,就会经历以下过程–
- 偏向锁阶段
核心思想:“懒汉”模式,能不加锁,就不加锁,能晚加锁,就晚加锁,所谓的偏向锁,并非真的加锁了,只是做了一个非常轻量的标记,一旦有其他线程来竞争这个锁,就在另一个线程之前先把锁获取到,从偏向级锁升级到轻量级锁。 - 轻量级锁阶段
通过自旋锁的方式实现。
优势: ⼀旦锁被另外线程释放, 就能第⼀时间获取到锁.
劣势:比较消耗 CPU 资源.
与此同时,synchronized内部也会统计当前这个锁对象上,有多少个线程在参与竞争,当发现参与竞争的线程较多了,就会进一步升级到重量级锁。
对于自旋锁来说,如果同一个锁竞争者很多,大量的线程都在自旋,整体cpu的消耗很大。 - 重量级锁阶段
此时拿不到锁的线程就不会继续自旋了,而是进入“阻塞等待”,让出cpu(不会使cpu占用率太高),当当前线程释放锁的时候,就由系统随机唤醒一个线程来获取锁了。
synchronized背后涉及很多的“优化手段”
①锁升级。偏向锁->轻量级锁->重量级锁
②锁消除。自动干掉不必要的锁
③锁粗化。把多个细粒度的锁合并成一个粗粒度的锁,减小锁竞争的开销。
以上机制在内部,在看不到的地方默默发挥作用。
三、CAS
CAS—全称 Compare and swap, 即 “⽐较并交换”. 相当于通过⼀个原⼦的操作, 同时完成 “读取内存, ⽐较是否相等, 修改内存” 这三个步骤. 本质上需要 CPU 指令的⽀撑.
CAS 可以视为是⼀种乐观锁
package Thread;
import java.util.concurrent.atomic.AtomicInteger;
//CAS方式实现(原子类),比加锁更高效,不涉及阻塞等待
public class ThreadDemo30 {
//不使用原生的int,也不加锁保证线程安全,而是替换成AtomicInteger
//private static int count=0;
private static AtomicInteger count=new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Object locker=new Object();
Thread t1=new Thread(()->{
for (int i = 0; i < 50000; i++) {
//count++;
count.getAndIncrement();
}
});
Thread t2=new Thread(()->{
for (int i = 0; i < 50000; i++) {
count.getAndIncrement();
}
});
t1.start();
t2.start();
// 如果没有这俩 join, 肯定不⾏的. 线程还没⾃增完, 就开始打印了. 很可能打印出来的 count是初始未累加的值0,或者计算过程中的不准确的值
t1.join();
t2.join();
// 预期结果应该是 10w
System.out.println("count: " + count.get());
}
}
AtomicInteger 的实现原理是什么?
基于 CAS 机制. 伪代码如下:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
四、JUC(java.util.concurrent) 的常见类
4.1Callable 接⼝
Callable 是⼀个 interface . 相当于把线程封装了⼀个 “返回值”. ⽅便程序猿借助多线程的⽅式计算结果.
eg.计算 1 + 2 + 3 + … + 1000
• 创建⼀个匿名内部类, 实现 Callable 接⼝. Callable 带有泛型参数. 泛型参数表⽰返回值的类型.
• 重写 Callable 的 call ⽅法, 完成累加的过程. 直接通过返回值返回计算结果.
• 把 callable 实例使⽤ FutureTask 包装⼀下.
• 创建线程, 线程的构造⽅法传⼊ FutureTask . 此时新线程就会执⾏ FutureTask 内部的 Callable 的call ⽅法, 完成计算. 计算结果就放到了 FutureTask 对象中.
• 在主线程中调⽤ futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的
结果.
package Thread;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
//计算 1 + 2 + 3 + ... + 1000
public class ThreadDemo32 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
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;
}
};
//Thread t=new Thread(callable); //error,Thread没有提供构造函数来传入callable
FutureTask<Integer> futureTask=new FutureTask<>(callable);
Thread t=new Thread(futureTask);
t.start();
//接下来这个代码不需要join,使用futureTask获取到结果
System.out.println(futureTask.get());
//futureTask.get()这个操作也是带有阻塞功能的,如果线程还没执行完毕,get就会阻塞,等到线程执行完了,return的结果就会被get返回回来
}
}
4.2ReentrantLock
ReentrantLock --可重入锁,和 synchronized 定位类似, 都是⽤来实现互斥效果, 保证线程安全。
ReentrantLock 的⽤法:
• lock(): 加锁, 如果获取不到锁就死等.
• trylock(超时时间): 加锁, 如果获取不到锁, 等待⼀定的时间之后就放弃加锁.
• unlock(): 解锁
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// working
} finally {
lock.unlock()
}
ReentrantLock 和 synchronized 的区别:
• synchronized 是⼀个关键字, 是 JVM 内部实现的(⼤概率是基于 C++ 实现). ReentrantLock 是标准库的⼀个类, 在 JVM 外实现的(基于 Java 实现).
• synchronized 使⽤时不需要⼿动释放锁. ReentrantLock 使⽤时需要⼿动释放. 使⽤起来更灵活, 但是也容易unlock. • synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock的⽅式等待⼀段时间就放弃.
• synchronized 是⾮公平锁, ReentrantLock 默认是⾮公平锁.可以通过构造⽅法传⼊⼀个 true 开启公平锁模式.
• 更强⼤的唤醒机制. synchronized 是通过 Object 的 wait
/ notify 实现等待-唤醒. 每次唤醒的是⼀个随机等待的线程. ReentrantLock 搭配 Condition类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.
多线程中有了synchonized,为什么不能完全替代reentrantlock?
虽然synchronized和ReentrantLock都是用来同步多线程访问共享资源的工具,但它们之间仍有一些重要的区别:
1.ReentrantLock可以通过tryLock()方法来尝试获取锁而不阻塞线程,而synchronized在获取不到锁时会一直阻塞线程,直到获取到锁为止。ReentrantLock可以实现公平性,通过构造函数传入true参数来创建一个公平锁,而synchronized是非公平的。
2.ReentrantLock可以实现条件等待和唤醒,通过Condition对象来实现线程的等待和唤醒操作,而synchronized无法直接实现类似的功能。
3.ReentrantLock提供了更多的灵活性,synchronized 使⽤时不需要⼿动释放锁. ReentrantLock 使⽤时需要⼿动释放,还可以设置超时时间、可重入性、中断响应等功能,而synchronized相对简单。
总的来说,ReentrantLock相对于synchronized来说更为灵活和强大,但使用也更为复杂,需要开发者自行管理锁的获取和释放。在一般情况下,建议首选使用synchronized,只有在需要更为复杂的同步控制时才考虑使用ReentrantLock。
如何选择使⽤哪个锁?
锁竞争不激烈的时候, 使⽤ synchronized, 效率更⾼, ⾃动释放更⽅便.
锁竞争激烈的时候, 使⽤ReentrantLock, 搭配 trylock 更灵活控制加锁的⾏为, ⽽不是死等.
如果需要使⽤公平锁, 使⽤ReentrantLock.
4.3信号量 Semaphore
package Thread;
import java.util.concurrent.Semaphore;
//信号量
public class ThreadDemo33 {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore=new Semaphore(10);
semaphore.acquire();//请求操作
System.out.println("P操作");
semaphore.acquire();
System.out.println("P操作");
semaphore.release();//释放操作
}
}
semaphore解决线程不安全问题:
public class ThreadDemo34 {
private static int count=0;
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore=new Semaphore(1);
Thread t1=new Thread(()->{
for (int i = 0; i < 50000; i++) {
try {
semaphore.acquire();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
count++;
semaphore.release();
}
});
Thread t2=new Thread(()->{
for (int i = 0; i < 50000; i++) {
try {
semaphore.acquire();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
count++;
semaphore.release();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count="+count);
}
}
信号量听说过么?之前都⽤在过哪些场景下?
信号量, ⽤来表⽰ “可⽤资源的个数”. 本质上就是⼀个计数器. 使⽤信号量可以实现 “共享锁”,
⽐如某个资源允许 3 个线程同时使⽤, 那么就可以使⽤ P 操作作为加 锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回,后续线程再进⾏ P 操作就会阻塞等待, 直到前 ⾯的线程执⾏了 V 操作.
4.4CountDownLatch
同时等待 N 个任务执⾏结束.
• 构造 CountDownLatch 实例, 初始化 10 表⽰有 10 个任务需要完成.
• 每个任务执⾏完毕, 都调⽤ latch.countDown() . 在 CountDownLatch 内部的计数器同时⾃
减.
• 主线程中使⽤ latch.await(); 阻塞等待所有任务执⾏完毕. 相当于计数器为 0 了.
package Thread;
import java.util.Random;
import java.util.concurrent.CountDownLatch;
//使用CountDownLatch比较方便,如果使用join就只能使用每个线程执行一个任务
//借助CountDownLatch就可以让一个线程能执行多个任务
public class ThreadDemo35 {
public static void main(String[] args) throws InterruptedException {
//1.构造方法中的10表示10个线程/任务
CountDownLatch latch=new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
int id=i;
Thread t1=new Thread(()->{
Random random=new Random();
//random.nextInt(5) [0,5)
int time=(random.nextInt(5)+1)*1000;
System.out.println("线程"+id+"开始下载");
try {
Thread.sleep(time);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("线程"+id+"结束下载");
//2.告知CountDownLatch执行结束
latch.countDown();
});
t1.start();
}
//3.通过这个await操作来等待所有任务结束,也就是countDown被调用了10次
latch.await();
System.out.println("所有任务都完成");
}
}
以上,synchronized, ReentrantLock, Semaphore 等都可以用于线程同步。
4.5线程安全的集合类
多线程环境使⽤队列
- ArrayBlockingQueue—基于数组实现的阻塞队列
- LinkedBlockingQueue—基于链表实现的阻塞队列
- PriorityBlockingQueue—基于堆实现的带优先级的阻塞队列
- TransferQueue—最多只包含⼀个元素的阻塞队列
多线程环境使⽤哈希表
HashMap 本⾝不是线程安全的.
在多线程环境下使⽤哈希表可以使⽤:
• Hashtable
• ConcurrentHashMap
- Hashtable
只是简单的把关键⽅法加上了 synchronized 关键字
public synchronized V put(K key,V value){}
public synchronized V get(Object key){}
一个Hashtable只有一把锁,两个线程访问Hashtable中的任意数据都会出现锁竞争。
- ConcurrentHashMap
ConcurrentHashMap每个哈希桶都有一把锁,只有两个线程访问的恰好是同一个哈希桶上的数据才出现锁冲突。
介绍下 ConcurrentHashMap的锁分段技术?
这个是 Java1.7 中采取的技术. Java1.8 中已经不再使⽤了.简单的说就是把若⼲个哈希桶分成⼀个"段" (Segment), 针对每个段分别加锁.
⽬的也是为了降低锁竞争的概率.当两个线程访问的数据恰好在同⼀个段上的时候, 才触发锁竞争.
HashMap和Hashtable和ConcurrentHashMap之间的区别(经典面试题)
HashMap: 线程不安全.因此在多线程环境下操作HashMap可能会导致并发竞争问题.key 允许为 null
Hashtable: 线程安全. 使⽤ synchronized 锁Hashtable 对象, 效率较低. key 不允许为 null.
ConcurrentHashMap: 线程安全. 可以在多线程环境下进行并发操作而不需要额外的同步措施.使⽤synchronized 锁每个链表头结点, 锁冲突概率低, 充分利⽤ CAS 机制. 优化了扩容⽅式. key 不允许为 null
总的来说,HashMap在单线程环境下具有更好的性能,而ConcurrentHashMap则适用于多线程环境下对Map进行并发操作的场景。Hashtable则逐渐被淘汰,一般不推荐使用
最后,码字不易,如果觉得对你有帮助的话请点个赞吧,关注我,一起学习,一起进步!