目录
一、常见的锁策略
1、乐观锁和悲观锁
- 乐观锁:以乐观的心态看待线程冲突(总觉得没有线程会同时操作共享变量)。所以每次都不加锁(程序层面),直接操作变量。(乐观锁在Java中的一种实现CAS)
- 悲观锁:以悲观的心态看待线程冲突(总觉得其他线程会同时操作共享变量)。所以每次都会加锁操作共享变量。
适用场景:
- 乐观锁适用于基本不存在线程冲突的程序;
- 悲观锁适用于大部分情况下存在线程冲突的程序。
2、读写锁
多线程之间,数据的读操作之间不会产生线程安全问题,但数据的写入方之间以及和读方之间都需要进行互斥。如果两种操作都使用同一种锁,就会产生很大的性能消耗。读写锁因此产生。
读写锁就是将读和写区分对待,Java标准库中提供了ReentrantReadWriteLock类,实现了读写锁:
- ReentrantReadWriteLock.ReadLock类表示一个读锁,提供lock/unlock方法进行加锁/释放锁;
- ReentrantReadWriteLock.WriteLock类表示一个写锁,也提供了lock/unlock方法进行加锁/释放锁。
其中,读读不互斥,读写/写写之间互斥。
读写锁非常适用于“频繁读,不频繁写”的场景。
3、轻量级锁和重量级锁
锁的核心特性“原子性”:
- cpu提供了“原子操作指令”;
- 操作系统基于cpu的原子指令,实现了mutex互斥锁;
- JVM基于操作系统实现的互斥锁,实现了Synchronized和ReentrantLock等关键字和类。
重量级锁:加锁机制重度依赖os提供的mutex。
- 大量的内核态用户态切换;
- 很容易引发线程的调度。
轻量级锁:加锁机制尽可能不使用mutex,尽量在用户态代码完成。实在不行,再使用mutex。
- 少量的内核态用户态切换;
- 不太容易引发线程调度。
4、自旋锁
线程在强锁失败后进入阻塞状态,放弃cpu,需要很久才能被再次调度。大部分情况下,虽然当前强锁失败,但过不了多久锁就会被释放。自旋锁:如果获取锁失败,立即再次尝试获取锁,知道获取到锁为止。一旦锁被其他线程释放,就能第一时间获取到锁。
自旋锁一般搭配乐观锁实现。
语法如下:(因为可以使用while循环来实现)
自旋锁是一种典型的轻量级锁的实现方法:
- 优点:没有放弃cpu,不存在线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁;
- 缺点:如果锁被其他线程持有的时间比较长,就会持续地耗费cpu资源(挂起等待的时候不消耗cpu)
5、公平锁和非公平锁
- 公平锁:遵循“先来后到”的规则;
- 非公平锁:不遵循“先来后到”的规则。
【1】优点:效率更高
【2】缺点:可能出现线程饥饿(某些线程长时间不能执行)的现象
【注】
- 操作系统内部的线程调度就是随机的。如果不做任何的额外限制,锁就是非公平锁。要想实现公平锁,就需要依赖额外的数据结构,来记录线程的先后顺序。
- 公平锁和非公平锁没有好坏之分。
6、可重入锁和不可重入锁
可重入锁:允许同一个线程多次获取同一把锁。
比如一个递归函数中有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,就是可重入锁。(可重入锁也叫递归锁)。
Java中只要以Reentrant开头命名的锁都是可重入锁。JDK提供的Lock类、以及synchronized都是可重入锁。
二、CAS(Compare and swap)
1、CAS
CAS:乐观锁(加锁的一种思想,程序看是没锁的)的一种实现
CAS的实现原理:
- Java中的CAS的实现利用的是unsafe类提供的cas方法;
- 以上cas又是基于操作系统+cpu提供的机制来实现。
2、CAS的ABA问题
ABA问题:读和写操作比较主存中变量的值时,值没有变,但实际已经被修改过(A->B->A)
解决方案:
给要修改的值,引入版本号。在CAS比较数据当前值和旧值时,也要比较版本号是否相同。
- CAS操作在读取值时,同时也要读取版本号;
- 真正修改时:
【1】如果当前版本号和读到的版本号相同,则修改数据,并把版本号+1;
【2】如果当前版本号高于读到的版本号,操作失败(说明数据已经被修改过)。
三、synchronizd原理
1、基本特点
- 初始使用乐观锁,当发现锁竞争比较频繁时,自动切换成悲观锁;
- 开始是轻量级锁,如果锁被持有的时间较长,就转换为重量级锁;
- 是一种不公平锁;
- 是一种可重入锁;
- 实现轻量级锁时大概率用到的是自旋锁策略;
- 不是读写锁。
2、synchronized原理
通过对象头加锁的方式实现线程安全,申请同一个对象锁时,多个线程间同步互斥。
对象中有一个对象头,其中就有一个状态的字段:无锁、偏向锁、轻量级锁、重量级锁。synchronized申请锁成功,就是后三种锁状态之一。
synchronized编译为字节码文件后,里面存在一个头对象的monitor机制(监视器)。
2、JVM对锁的升级(加锁的过程)
(1)偏向锁
第一次尝试加锁的线程,优先进入偏向锁状态。
偏向锁的本质上是“延迟加锁”,能不加锁就不加锁,避免不必要的加锁开销。
偏向锁的原理:
偏向锁不是真的加锁,只是给对象头中做一个标记,记录这个锁属于哪个线程。如果后续没有其他线程来竞争该锁,就不需要进行加锁操作;如果后续有其他线程来竞争该锁(之前做了标记,很容易识别当前申请锁的线程是不hi是之前标记的线程),取消原来的偏向锁状态,进入轻量级锁状态。
(2)轻量级锁
随着其它线程进入竞争,偏向锁状态解除,进入轻量级锁状态(自适应的自旋锁+CAS)。
此处的自旋锁通过CAS来实现:
- 通过CAS检查并更新一块内存;
- 如果更新成功,则认为加锁成功;
- 如果更新失败,则认为锁被占用,继续自旋式等待(不放弃CPU)
【自旋操作是一直让cpu空转,比较消耗cpu资源,因此此处的自旋不会一直持续下去,到达一定的时间/次数后,就不再自旋,这就是“自适应”】
(3)重量级锁
如果竞争进一步激烈,自选不能快速获取到锁状态,则变为重量级锁(指的是内核提供的mutex)。
- 执行加锁操作,先进入内核态;
- 在内核态判断当前锁是否已经被占用;
- 如果没有被占用,则加锁成功,切换回用户态;
- 如果被占用,则加锁失败,线程进入等待状态,等待被再次唤醒;
- 当锁被释放后,这个线程被唤醒,重新获取锁
3、其它优化操作
(1)锁消除
编译器+jvm判断锁是否可以消除。如果可以,直接消除。
如以下代码:
此时每个append都会涉及到枷锁和释放锁。但如果只是在单线程中执行这些代码,那么这些加锁解锁操作是没有必要的,白白浪费一些资源。
(2)锁粗化
共享变量可能被其他线程持有,但不停的执行加锁/释放锁的操作。jvm就可以优化为第一次执行时加锁,全部执行完后再释放锁。
对于以上代码,会在第一次拼接之前进行加锁,直到所有的都拼接完后再释放锁。
四、JUC的常见类
JUC:java.util.concurrent包,Java的并发包。提供了很多多线程需要的且效率很高的API。
1、ReenTrantLock
可重入锁,属于独占锁。通过构造函数提供公平锁和非公平锁。
ReentrantLock用法:
- lock():加锁,获取不到就死等;
- trylock(time):加锁,如果获取不到锁,等待一段时间后放弃加锁;
- unlock():解锁
synchronized内部有解锁机制,但是lock需要手动解锁,需要注意如果使用lock加锁的线程在执行中出现异常,可能导致锁无法释放,造成死锁,需要使用finally来执行锁的释放。
synchronized和ReentrantLock区别
- synchronized是一个关键字,而ReentrantLock是标准库中的类;
- synchronized使用时不需要手动释放锁,而ReentrantLock需要手动释放锁(就会存在一个问题,如果加锁的代码出现异常,线程会异常终止,此时就需要使用finally来保证锁的释放)
- ReentrantLock提供了公平锁和非公平锁,而synchronized只是非公平锁;
- 线程竞争激烈时,使用ReentrantLock更高效
【1】释放锁之后,synchronized是唤醒所有的线程来竞争(设计线程状态转换,存在性能消耗)
【2】ReentrantLock只是唤醒一个线程(使用aqs来管理线程)- synchronized申请锁失败时,会死等;而ReentrantLock可以通过trylock的方式等待一段时间后就放弃。
2、原子类(原子性并发包)
内部使用的是CAS+自旋锁,所以性能比加锁实现i++要高得多。常见原子性并发包如下:
- AtomicBoolean
- AtomicInteger
- AtomicIntegerArray
- AtomicLong
- AtomicLongArray
以AtomicInteger为例:
3、信号量Semaphore
信号量,用来表示“可用资源的个数”,本质上就是一个计数器。
- acquire方法表示申请资源(P操作)
- release方法表示释放资源(V操作)
- PV操作的加减计数器都是原子的,可以在多线程环境下直接使用
使用场景:
- 有限资源的并发执行:
- 多个线程并发执行,需要全部执行完,某个线程再执行后续的代码:
4、CountDownLatch
同时等待N个线程结束。
五、Callable接口
提供一个带返回值的任务描述方法,结合Futrue相关的API,就可以获取线程的执行结果。
Callable:
- Callable和Runnable相对,都是描述一个任务。不同的是,Callable描述的是带有返回值的任务,Runnable描述的是不带返回值的任务;
- Callable通常搭配FutrueTask来使用,FutrueTask来保存Callable返回的结果。因为Callable往往是在另一个线程中执行(不确定执行完成的时间);
- FutrueTask就负责等待这个结果出来
例:使用Callable创建线程实现1~1000求和。
- 创建匿名内部类实现Callable接口。Callable带有泛型参数,表示返回值的类型;
- 重写Callable的call方法,完成累加,返回计算结果;
- 将Callable实例使用FutureTask包装;
- 创建线程,构造方法传入FutureTask。此时新线程会执行FutureTask内部的Callable的call方法,完成计算,结果放在FutrueTask的对象中;
- 在主线程中调用futuretask.get()能够阻塞等待新线程计算完毕,并获取到计算结果。
【代码如下】
public class CreateThread {
public static void main(String[] args) throws ExecutionException, InterruptedException {
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;
}
};
FutureTask<Integer> futureTask=new FutureTask<>(callable);
Thread t=new Thread(futureTask);
t.start();
int ret=futureTask.get();
System.out.println(ret);
}
}
六、线程安全的集合类
1、List(了解)
(1)Collections.synchronizedList(new ArrayList):
返回一个同步的List,内部使用synchronized保证线程安全,效率不高。
(2)CopyOnWriteArray:写时复制容器
- 进行写操作时,复制一个新的容器,往新的容器中添加元素,此时旧的容得容器仍然可以进行读操作。
- 添加完元素后,再将旧的容器的引用指向新的容器。
优点:读读/读写并发并行执行(在读多写少的情况下,性能很高,不需要进行锁竞争)。但占用的内存较多(空间换时间)。
2、Map
(1)hashtable
只是简单的将关键的方法加synchronized关键字,相当于给hashtable对象本身加锁。
- 多线程访问同一个hashtable对象直接造成锁冲突;
- 一旦扩容,该线程完成整个扩容过程,涉及到大量的元素拷贝,效率很低。
(2)ConcurrenthashMap
1.8的实现:数组+链表+红黑树
- 读操作不加锁(使用volatile保证)。只对写操作进行加锁操作,使用synchronized加锁,只是锁每次操作对应的“桶”(每个链表的头节点作为锁对象),大大降低锁的冲突概率。
- 扩容方式:化整为零
【1】需要扩容时,创建新的数组,同时只搬几个元素过去,
【2】扩容期间新老数组同时存在
【3】扩容时,读操作需要同时查找新老数组
【4】插入操作只往新数组中查
【5】后续每个操作ConcurrentHashMap的线程,都会参与搬家操作,每个操作搬一小部分
【6】搬完最后一个元素老数组删除
1.7的实现:数组+链表
- 加锁方式:分段锁
- hash冲突严重时,链表会比较长,查询时间复杂度很高
七、死锁
死锁是一种情形:多个线程同时被阻塞,他们中的一个或多个都在等待某个资源被释放。由于线程被无限期的阻塞,因此程序不可能正常停止。
【死锁产生的四个必要条件】
- 互斥使用:当资源被一个线程占有时,另一个线程不能访问;
- 不可抢占:资源请求者不可从资源占有者手中夺取资源,资源只能有资源占有者自动释放;
- 请求和保持:资源请求者在请求别的资源时仍然保持对原有资源的占有;
- 循环等待:存在一个循环队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。形成了一个等待环路。
上述四个条件都成立时,形成死锁。死锁的情况下打破上述任意一个条件,便可让死锁消失。(最容易破坏的是“循环等待”)
解决死锁问题:
破坏这个循环等待的条件:比如大家按照一定的顺序来申请锁。