目录
1、乐观锁 VS 悲观锁
乐观锁与悲观锁的实现方式(含实例)
乐观锁的实现方式主要有两种:CAS机制和版本号机制
乐观锁和悲观锁优缺点和适用场景
乐观锁加锁吗?
CAS有哪些缺点?
2、普通互斥锁 VS 读写锁
3、轻量级锁 VS 重量级锁
4、自旋锁 VS 挂起等待锁
5、公平锁 VS 非公平锁
6、可重入锁 VS 不可重入锁
7、Synchronized 原理
7.1、锁升级(锁膨胀)
(1、偏向锁——无竞争
(2、轻量级锁——有竞争
(3、重量级锁——竞争激烈
7.2、锁消除
7.3、锁粗化
8、JUC(java.util.concurrent) 的常见类
8.1、Callable 接口
8.2、ReentrantLock
8.3、原子类
8.4、线程池
8.5、信号量Semaphore
8.6、CountDownLatch
9、线程安全的集合类
9.1、多线程环境使用ArrayList
9.2、多线程环境使用队列
9.3、多线程环境使用哈希表
10、死锁
10.1、什么是死锁
10.2、如何避免死锁
10.3、解决哲学家就餐问题
11、 Java多线程是如何实现数据共享的?
乐观锁、悲观锁顾名思义就是,形容心态,乐观把事情往好了想,悲观把事情往坏了想
乐观锁概念
乐观锁在操作数据的时候,非常乐观,都认为别人不会同时修改数据,所以乐观锁在更新数据之前,都不会对数据进行加锁,只有当执行更新数据操作时候,再去判断数据是否被修改,如果数据被修改了,就放弃被当前修改操作。
悲观锁概念
悲观锁在操作数据时候,比较悲观,都认为别人会和自己同时修改数据,所以悲观锁操作数据时候,会直接给数据上锁,不让别人操作,只有自己操作完成后,才释放锁。
乐观锁与悲观锁的实现方式(含实例)
在说明实现方式之前,需要明确:乐观锁和悲观锁是两种思想,它们的使用是非常广泛的,不局限于某种编程语言或数据库。
悲观锁的实现方式是加锁,加锁既可以是对代码块加锁(如synchronized关键字),也可以是对数据加锁(如MySQL中的排它锁)。
乐观锁的实现方式主要有两种:CAS机制和版本号机制
(1)CAS(Compare And Swap)
CAS操作包括了3个操作数:
1) 需要读写的内存位置(V)
2) 进行比较的预期值(A)
3) 拟写入的新值(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:多个线程多把锁
一个经典的问题就是哲学家就餐问题,该问题是描述有五个哲学家共用一张圆桌,分别坐在周围的五张椅子上,在圆桌上有五个碗和五只筷子,他们的生活方式是交替地进行思考和进餐。平时,一个哲学家进行思考,饥饿时便试图取用其左右最靠近他的筷子,只有在他拿到两只筷子时才能进餐。进餐完毕,放下筷子继续思考。
五个哲学,都先拿起自己左手边的筷子,导致,都吃不了饭,僵持不下,死锁
死锁产生的四个必要条件:
破坏循环等待:
三个方案:
下期见!!!