目录
8、JUC(java.util.concurrent) 的常见类
乐观锁、悲观锁顾名思义就是,形容心态,乐观把事情往好了想,悲观把事情往坏了想
乐观锁概念
乐观锁在操作数据的时候,非常乐观,都认为别人不会同时修改数据,所以乐观锁在更新数据之前,都不会对数据进行加锁,只有当执行更新数据操作时候,再去判断数据是否被修改,如果数据被修改了,就放弃被当前修改操作。
悲观锁概念
悲观锁在操作数据时候,比较悲观,都认为别人会和自己同时修改数据,所以悲观锁操作数据时候,会直接给数据上锁,不让别人操作,只有自己操作完成后,才释放锁。
乐观锁与悲观锁的实现方式(含实例)
在说明实现方式之前,需要明确:乐观锁和悲观锁是两种思想,它们的使用是非常广泛的,不局限于某种编程语言或数据库。
悲观锁的实现方式是加锁,加锁既可以是对代码块加锁(如synchronized关键字),也可以是对数据加锁(如MySQL中的排它锁)。
乐观锁的实现方式主要有两种:CAS机制和版本号机制
(1)CAS(Compare And Swap)
CAS操作包括了3个操作数:
- 1) 需要读写的内存位置(V) - 内存值
- 2) 进行比较的预期值(A) - 旧预期值
- 3) 拟写入的新值(B) - 新值
- if(V = A){
- V = B
- }
因此,如果并发执行自增操作,可能导致计算结果的不准确。
在下面的代码示例中:value1没有进行任何线程安全方面的保护,value2使用了乐观锁(CAS),value3使用了悲观锁(synchronized)。
运行程序,使用1000个线程同时对value1、value2和value3进行自增操作,可以发现:value2和value3的值总是等于1000,而value1的值常常小于1000。
-
- public class Test {
-
- //value1:线程不安全
- private static int value1 = 0;
- //value2:使用乐观锁
- private static AtomicInteger value2 = new AtomicInteger(0);
- //value3:使用悲观锁
- private static int value3 = 0;
-
- private static synchronized void increaseValue3() {
- value3++;
- }
-
- public static void main(String[] args) throws Exception {
- //开启1000个线程,并执行自增操作
- for (int i = 0; i < 1000; ++i) {
- new Thread(new Runnable() {
- @Override
- public void run() {
- try {
- Thread.sleep(100);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- value1++;
- value2.getAndIncrement();
- increaseValue3();
- }
- }).start();
- }
- //查看活跃线程
- while (Thread.activeCount() > 2) {
- //Thread.currentThread().getThreadGroup().list();
- Thread.yield();//让出cpu
- }
-
- //打印结果
- Thread.sleep(1000);
- System.out.println("线程不安全:" + value1);
- System.out.println("乐观锁(AtomicInteger):" + value2);
- System.out.println("悲观锁(synchronized):" + value3);
- }
- }
1)首先来介绍AtomicInteger。
AtomicInteger是java.util.concurrent.atomic包提供的原子类,利用CPU提供的CAS操作来保证原子性;
除了AtomicInteger外,还有AtomicBoolean、AtomicLong、AtomicReference等众多原子类。
源码分析说明如下:
Unsafe是用来帮助Java访问操作系统底层资源的类(如可以分配内存、释放内存,在netty中大量用到它,属于C++层面的native方法,我们一般使用反射获取),通过Unsafe,Java具有了底层操作能力,可以提升运行效率;
强大的底层资源操作能力也带来了安全隐患(类的名字Unsafe也在提醒我们这一点),因此正常情况下用户无法使用。
AtomicInteger在这里使用了Unsafe提供的CAS功能。
CAS操作可以保证原子性,而volatile可以保证可视性和一定程度的有序性;
在AtomicInteger中,volatile和CAS一起保证了线程安全性。
2) 说完了AtomicInteger,再说synchronized。
synchronized通过对代码块加锁来保证线程安全:在同一时刻,只能有一个线程可以执行代码块中的代码。
synchronized是一个重量级的操作,不仅是因为加锁需要消耗额外的资源,还因为线程状态的切换会涉及操作系统核心态和用户态的转换;
不过随着JVM对锁进行的一系列优化(如自旋锁、轻量级锁、锁粗化等),synchronized的性能表现已经越来越好。
(2)版本号机制实现乐观锁
版本号机制的基本思路是在数据中增加一个字段version,表示该数据的版本号,每当数据被修改,版本号加1。
需要注意的是,这里使用了版本号作为判断数据变化的标记,实际上可以根据实际情况选用其他能够标记数据版本的字段,如时间戳等。
乐观锁和悲观锁优缺点和适用场景
乐观锁和悲观锁并没有优劣之分,它们有各自适合的场景;下面从两个方面进行说明。
例如,CAS只能保证单个变量操作的原子性,当涉及到多个变量时,CAS是无能为力的,而synchronized则可以通过对整个代码块加锁来处理。
再比如版本号机制,如果query的时候是针对表1,而update的时候是针对表2,也很难通过简单的版本号来实现乐观锁。
当竞争不激烈 (出现并发冲突的概率小)时,乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。
当竞争激烈(出现并发冲突的概率大)时,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。
乐观锁加锁吗?
下面是我对这个问题的理解:
但这只是乐观锁与加锁操作合作的例子,不能改变“乐观锁本身不加锁”这一事实。
CAS有哪些缺点?
面试到这里,面试官可能已经中意你了。
不过面试官准备对你发起最后的进攻:你知道CAS这种实现方式有什么缺点吗?
下面是CAS一些不那么完美的地方:
1.ABA问题 假设有两个线程——线程1和线程2,两个线程按照顺序进行以下操作:
在第(4)步中,由于内存中数据仍然为A,因此CAS操作成功,但实际上该数据已经被线程2修改过了。这就是ABA问题。
在AtomicInteger的例子中,ABA似乎没有什么危害。
但是在某些场景下,ABA却会带来隐患,例如栈顶问题:一个栈的栈顶经过两次(或多次)变化又恢复了原值,但是栈可能已发生了变化。
对于ABA问题,比较有效的方案是引入版本号,内存中的值每发生一次变化,版本号都+1;
在进行CAS操作时,不仅比较内存中的值,也会比较版本号,只有当二者都没有变化时,CAS才能执行成功。
Java中的AtomicStampedReference类便是使用版本号来解决ABA问题的。
2.高竞争下的开销问题 在并发冲突概率大的高竞争环境下,如果CAS一直失败,会一直重试,CPU开销较大。
针对这个问题的一个思路是引入退出机制,如重试次数超过一定阈值后失败退出。
当然,更重要的是避免在高竞争环境下使用乐观锁。
3.功能限制 CAS的功能是比较受限的,例如CAS只能保证单个变量(或者说单个内存值)操作的原子性,这意味着:
除此之外,CAS的实现需要硬件层面处理器的支持,在Java中普通用户无法直接使用,只能借助atomic包下的原子类使用,灵活性受到限制。
互斥锁
互斥锁是一种简单的加锁的方法来控制对共享资源的访问,互斥锁只有两种状态,即上锁( lock )和解锁(unlock),如果互斥量已经上锁,调用线程会阻塞,直到互斥量被解锁。在完成了对共享资源的访问后,要对互斥量进行解锁
互斥锁特点
读写锁
读写锁允许更改的并行性,写的串行性,也叫共享互斥锁。 互斥量要么是锁住状态,要么就是不加锁状态,而且一次只有一个线程可以对其加锁。 读写锁可以有3种状态:读模式下加锁状态、写模式加锁状态、不加锁状态。 一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁(允许多个线程读但只允许一个线程写)
读写锁特点
轻量级锁
轻量级锁:是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁
重量级锁: 是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低
自旋锁
自旋锁与互斥量功能一样,唯一一点不同的就是互斥量阻塞后休眠让出cpu,而自旋锁阻塞后不会让出cpu,会一直忙等待,直到得到锁,原地打转 自旋锁在用户态使用的比较少,在内核使用的比较多!自旋锁的使用场景:锁的持有时间比较短,或者说小于2次上下文切换的时间。
自旋锁特点
挂起等待锁
如果线程获取不到锁就会堵塞等待,啥时候结束等待就要看cpu的调度策略,在挂起的时候是不吃cpu的资源的
公平锁
非公平锁
对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。 对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。
打一个不恰当的例子,公共厕所男女一起排队,当厕所里面是将男女分开的,这时当里面一个男的出来了,而外面排队的前三个人都是女的,队伍中就会直接让第四个人进去,此时的优先级就是男>女的,也就是非公平锁了
注:
可重入锁
为了解决互斥锁导致的死锁问题(哲学家吃面问题),引入可重入锁又叫递归锁
可重入内部维护着一个锁和一个计数器,计数器记录了获取锁的次数,从而使得资源可以被同一个线程多次获取,直到一个线程所有的获取都被释放,其他的线程才能获得资源
不可重入锁
不可重入锁:与可重入锁相反,不可递归调用,递归调用就发生死锁。
以上六组就是我们常见的锁策略
(1、偏向锁——无竞争
偏向锁并不是真的“加锁”,而是给对象头中做一个“偏向锁的标记”,记录这个锁属于哪个线程
如果后续没有其他线程来竞争这个锁,那就不用加锁了,可以避免加锁解锁带来的开销
但是如果后续有其他线程来竞争该锁,(因为它已经做了标记,很容易识别 它是否是刚才记录的线程),就会取消偏向锁状态,进入一般的轻量级锁状态
怎么去理解呢?其实就是危机感的问题,比如,那么男女交往过程中,双方确定关系,只是彼此知道而已,但别人并不知道,他们也没有官宣(官宣---加锁,官宣分手---需要开销即需要时间),此时就相当于偏向锁,而此时,突然出现了一个男配或者女配的时候,他们产生了危机感,就会立即官宣(加锁)。再打个比方,同志A去上厕所,他没有习惯关门(关门---加锁,开门---解锁,卡开关门的操作需要ATP,消耗能量),每次听到外面有脚步声来了,才会赶快去关上门。
(2、轻量级锁——有竞争
(3、重量级锁——竞争激烈
锁消除其实就是JVM做出的一个优化,JVM自动判定这个地方是不需要锁的,如果你加了Synchronized,就会自动的把锁去掉
这里JVM是在有百分之百的把握下才会进行锁消除
就是把细粒度的加锁优化为粗粒度的加锁,如图:
这个包中放的都是与多线程相关的
Callable 接口创建线程:http://t.csdn.cn/skpC0
ReentrantLock 的用法:
lock(): 加锁 , 如果获取不到锁就死等 .trylock( 超时时间 ): 加锁 , 如果获取不到锁 , 等待一定的时间之后就放弃加锁 .unlock(): 解锁
ReentrantLock 和 synchronized的区别:
如何选择使用哪个锁?
原子类的底层是基于CAS实现的,这里使用API文档查看即可
synchronized相比于CAS,虽然没有CAS好用,但是synchronized的打击面广,使用场景更多,更加通用
这篇博文已经详细总结了:http://t.csdn.cn/f1a9s
另外利用线程池创建线程:http://t.csdn.cn/skpC0
信号量,用来表示“可用资源的个数”,本质上就是一个计数器
信号量的两个基本操作:
例:
- public class Test {
- public static void main(String[] args) throws InterruptedException {
- //指定计数器的初始值,表示有几个资源
- Semaphore semaphore = new Semaphore(4);
- //P-操作,申请资源,计数器-1
- semaphore.acquire();
- System.out.println("P操作");
- semaphore.acquire();
- System.out.println("P操作");
- semaphore.acquire();
- System.out.println("P操作");
- //V-操作,释放资源,计数器+1
- semaphore.release();
- System.out.println("V操作");
- semaphore.acquire();
- System.out.println("P操作");
- semaphore.acquire();
- System.out.println("P操作");
- semaphore.acquire();
- System.out.println("P操作");//这个不会输出,阻塞等待了
- }
- }
CountDownLatch允许一个或者多个线程去等待其他线程完成操作。
例如,在跑步中,等待所有人都跑完了,才算比赛结束:
- import java.util.concurrent.CountDownLatch;
-
- public class Test {
- public static void main(String[] args) throws InterruptedException {
- // 有 10 个选手参加了比赛
- CountDownLatch countDownLatch = new CountDownLatch(10);
- for (int i = 0; i < 10; i++) {
- // 创建 10 个线程来执行一批任务.
- Thread t = new Thread(() -> {
- System.out.println("选手出发! " + Thread.currentThread().getName());
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- System.out.println("选手到达! " + Thread.currentThread().getName());
- // 撞线
- countDownLatch.countDown();
- });
- t.start();
- }
-
- // await 会等到所有的选手都撞线
- countDownLatch.await();
- System.out.println("比赛结束!");
- }
- }
CountDownLatch提供的一些方法:

1)、自己使用同步机制 (synchronized 或者 ReentrantLock)
2)、Collections.synchronizedList(new ArrayList);
这个方法固然是好的,但是更建议自己手动加锁,因为,有的地方可能就是不需要加锁,而这个方法是给所有都加锁了,难免会降低效率的
3)、使用 CopyOnWriteArrayList
HashMap是线程不安全的
在多线程环境下,使用哈希表可以使用:
1)、 Hashtable
这里是无脑给各种方法加synchronized
2)、 ConcurrentHashMap
ConcurrentHashMap核心优化思路,尽可能降低锁冲突的概率
注:产生锁冲突,对于性能影响是非常大的
例如:维护元素个数,都是通过CAS实现,而不是加锁
HashTable扩容:
当添加元素时,发现负载因子已经超过阈值时,触发扩容机制,申请一个更大的数组,将原来的数据搬运至新的数组上
问题:
当需要搬运的元素特别多的时候,该操作的开销会非常大,就可能会导致添加某个元素时,卡了很久才添加上,严重的甚至导致请求添加失败
ConcurrentHashMap扩容优化:
扩容时,不是一次性将所有数据全部搬运过去,而是依次搬运一点,就像愚公移山一样
并且,在扩容过程中,旧的数据和新的数据会同时存在一段时间,直到搬运完成,才会释放旧的数据的空间
在这个过程中,如果要进行查询元素,就会新、旧数据一起查询,插入数据,直接插入,删除数据,如果该数据还在旧的数据空间中,就不用搬运该数据了
补充两点:
ConcurrentHashMap中旧版本的实现方式,好几个链表分一个锁,锁冲突概率更高,Java8开始就不是这样了,而是一个链表分一个锁
场景举例:
例1: 一个线程一把锁
当一个线程一把锁,连续加锁两次,导致死锁,但是如果该锁是可重入锁,就不会出现死锁情况(synchronized就是可重入锁)
例2:两个线程两把锁
例如有两个线程:

线程A需要先锁a,再锁b,而线程B需要先锁b再锁a,那么就会出现,A把a锁了,B把b锁了,两个互不相让 ,僵持在这里,这时就构成了一个死锁的状态
换句话来说,就是车钥匙锁在家里,而家钥匙锁在车里了,就是一个死锁的状态
看代码:
死锁情况:将上述两个synchronized进行嵌套,也就是说,第一把锁还没有释放,就得给第二个加锁
- public class Test {
- public static void main(String[] args) {
- Object locker1 = new Object();
- Object locker2 = new Object();
-
- Thread t1 = new Thread(() -> {
- System.out.println("t1尝试获取locker1");
- synchronized (locker1) {
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
-
- System.out.println("t1尝试获取locker2");
- synchronized (locker2) {
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
- System.out.println("t1获取两把锁成功");
- }
- }
- });
- Thread t2 = new Thread(() -> {
- System.out.println("t2尝试获取locker2");
- synchronized (locker2) {
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
-
- System.out.println("t2尝试获取locker1");
- synchronized (locker1) {
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
- System.out.println("t1获取两把锁成功");
- }
- }
- });
- t1.start();
- t2.start();
- }
- }
例3:多个线程多把锁
一个经典的问题就是哲学家就餐问题,该问题是描述有五个哲学家共用一张圆桌,分别坐在周围的五张椅子上,在圆桌上有五个碗和五只筷子,他们的生活方式是交替地进行思考和进餐。平时,一个哲学家进行思考,饥饿时便试图取用其左右最靠近他的筷子,只有在他拿到两只筷子时才能进餐。进餐完毕,放下筷子继续思考。

五个哲学,都先拿起自己左手边的筷子,导致,都吃不了饭,僵持不下,死锁
死锁产生的四个必要条件:
破坏循环等待:
下期见!!!