《JavaEE初阶》多线程进阶
常见锁策略
锁策略是什么?
即加锁的时候应该怎么加才比较合适.
锁策略与编程语言Java关系不大,其他编程语言也有"锁策略"
乐观锁与悲观锁
乐观锁: 预测接下来锁的冲突概率较小,做一类操作.
悲观锁: 预测接下来锁的冲突概率较大, 做一类操作.
举个例子:
线程B需要访问线程A,线程B可以做两种选择:
-
先提前告知线程A某个时间段需要去访问.如果线程A 在这个时间比较空闲,在得到线程A的许可,那么到达某一时间.在操作系统的调度下,线程B再去访问线程A ,这是悲观锁思想
-
线程B直接去访问线程A,如果线程A较忙,则下一次再继续来访问,如果线程A较闲,则可以直接访问到. 这是乐观锁思想.
这两种锁没有优劣之分,只要符合需求场景即可:
-
乐观锁往往是纯用户态执行的,这也意味着效率比较高,但是也会带走CPU资源
-
悲观锁是需要内核执行的,对当前线程进行挂起等待,这也意味着效率差.
synchronized是一个自适应锁,既是悲观锁也是乐观锁.
synchronized初始是一个乐观锁,当发现锁冲突的概率较大时,会转化为悲观锁.
普通互斥锁与读写锁
synchronized就是普通的互斥锁,即两个锁操作之间会发生竞争.
读写锁就相当于将加锁操作细化,加锁分为了"加读锁"和"加写锁".在执行加锁操作时需要额外表明读写意图,读者之间并不互斥,而写者则要求与任何人互斥
可分为三种情况:
-
线程A加写锁,线程B加写锁, 那会导致互斥.
-
线程A加读锁,线程B加读锁,那不会导致互斥,因为两个线程读取一份数据,是线程安全的.
-
线程A加写锁,线程B加读锁, 那会导致互斥.
Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.
轻量级锁与重量级锁
轻量级锁: 锁的开销比较小,做的工作比较少
重量级锁: 锁的开销比较大,做的工作比较多
对于悲观锁往往是重量级锁,而乐观锁往往是轻量级锁,但这并不是一定的.乐观锁一样可以是重量级锁,而悲观锁也一样可以是轻量级锁.
重量级锁由于依赖了操作系统提供的锁,所以就容易产生阻塞等待了.
轻量级锁采用的策略是尽量地避免使用操作系统提供的锁,尽量在用户态下完成功能,尽量避免内核态和用户态的切换,尽量地避免线程挂起等待.
synchronized 是自适应锁,既是轻量级锁也是重量级锁.synchronized会根据锁冲突的情况来自适应的转换.
自旋锁与挂起等待锁
自旋锁是轻量级锁的具体实现,自旋锁是轻量级锁也是乐观锁.
自旋锁在遇到锁冲突时不会使线程挂起等待,而是会立即尝试获取这个锁.
-
当锁被释放时,可以第一时间获取锁
-
但是如果锁一直不释放,那么会一直消耗CPU资源
挂起等待锁是重量级锁的具体实现,挂起等待锁是重量级锁也是悲观锁.
挂起等待锁在遇到锁冲突时,会直接挂起等待,等待操作系统的调度.
-
当锁被释放时,不能第一时间获取到锁
-
在锁被其他线程占用的时候,会放弃CPU资源.
synchronized在作为轻量级锁的时候,内部实现为自旋锁,在作为重量级锁的时候,内部实现为挂起等待锁.
公平锁和非公平锁
以三个线程为例:
当线程A拿到锁后,线程B尝试获取锁,获取失败,挂起等待.线程C尝试获取锁,获取失败,挂起等待.
当线程A 释放锁的时候.
如果是公平锁:
那么会符合"先来后到"的规则,由线程B先获取到锁,等线程B释放锁后,线程C才能获取.
如果是非公平锁:
那么会产生"机会均等"的竞争,线程B和线程C会产生竞争,都有可能获取到锁.
synchronized是非公平锁.
对于操作系统的挂起等待锁,也是非公平锁.
如果想要实现公平锁,需要借助额外的数据结构来记录线程的先后顺序来实现.
可重入锁和不可重入锁
当我们在同一个线程中加同一把锁多次:
public static void func(){
synchronized(locker){
synchronized(locker){
}
}
}
在这种情况下,在没有可重入锁的情况下:即不可重入锁:
会导致: 当线程第一次获取到锁时, 线程想要第二次获取到锁,但是线程第一次获取到锁了,所以会导致线程第二次尝试获取锁的时候,会阻塞等待.
也就是: 第一次释放锁依赖于第二次获取到锁,第二次获取到锁依赖于第一次释放锁.
直接导致了死锁的问题出现.
为了解决这个问题,引入了可重入锁的概念:
对于一个线程,可以同时对同一个锁加多次.可重入锁会在内部记录这个锁是哪个线程获取的,如果发现当前加锁的线程和持有锁的线程是同一个,则不会阻塞等待,而是直接获取到锁.同时在内部使用计数器来记录当前是第几次加锁了,利用计数器来控制啥时候释放锁.
对于synchronized而言,它是可重入锁.
CAS
什么是CAS
CAS是操作系统/硬件 提供给JVM 的一种更轻量的实现原子操作的机制.
CAS 是 CPU提供的一条特殊的指令 “compare and swap” , 是原子操作.
-
compare 是比较, 比较寄存器中的值与内存的值是否相等.
-
如果相等 , 则将寄存器中的值和另一个值进行交换.
-
如果不相等,则不进行操作.
即:
假设内存中的原数据A,旧的预期值B,需要修改的新值C。
-
比较 A 与 B 是否相等。(比较)
-
如果比较相等,将 C 写入 A。(交换)
-
返回操作是否成功。
伪代码:
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
CAS的应用场景:
-
利用CAS实现原子类.
在Java标准库java.util.concurrent.atomic包中, 利用CAS实现了许多原子类的操作,典型的就是 利用 CAS 实现 “i++” 操作
public static void main(String[] args) { AtomicInteger i = new AtomicInteger(); Thread t1 = new Thread(()->{ for(int m = 0;m < 1000000;m++){ i.getAndIncrement(); } }); Thread t2 = new Thread(()->{ for(int m = 0;m < 1000000;m++){ // 相当于i++ i.getAndIncrement(); } }); t1.start(); t2.start(); try { t1.join(); t2.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(i); }
我们可以看到运行结果是2000000, 是线程安全的.
实现伪代码:
class AtomicInteger { private int value; public int getAndIncrement() { int oldValue = value; while ( CAS(value, oldValue, oldValue+1) != true) { oldValue = value; } return oldValue; } }
-
利用CAS实现原子类.
伪代码:
public class SpinLock { private Thread owner = null; public void lock(){ while(!CAS(this.owner, null, Thread.currentThread())){ } } public void unlock (){ this.owner = null; } }
当owner为空时, 才可以CAS成功, 如果 owner 不为空, 则认为当前锁已经被其他线程占用,则需要继续循环( 实现 自旋)
CAS 的 ABA 问题
什么是CAS 的 ABA 问题
在CAS 中, 无法区分一个数据是否发生过转变, 例如数据x, CAS 无法判断 数据x 是否发生过
x -> y -> x 的情况.
这就可能引发bug,举个例子:
小明现在有存款 1000 块钱, 小明 想要取款 200 ,但是小明 不小心按了 取款机两下,创建了两个线程.
在正常的情况下:
线程1 和 线程2 同时读到了1000 .
线程1 将1000 与 1000 进行比较,发现 相等, 进行扣款,同时线程2阻塞等待.
线程2 将1000 与 扣款成功后的 800 进行比较 发现不相等,则不进行扣费.
在异常的情况下,
当线程1 扣款成功后,线程2 还没来的及执行的时候,小明的朋友给小明存钱200.这个时候线程2无法判断1000 与 1000 -> 800 -> 1000 的情况,就会认为两者相等 那么进行了又一次扣款.
这就引发了bug.
解决方案:
引入" 版本号 ":
我们在进行CAS操作时, 可以给 变量 加入一个 版本号, 我们在进行一次操作时, 都会让版本号只能++或者–或者其他保证在时间上不会出现相等的情况算法, 这样每一次变量只要发生了改变, 变量在时间上可能会发生相等的情况,但是版本号在时间上不会发生相等的情况
synchronized的工作原理:
synchronized使用的锁策略:
-
既是乐观锁也是悲观锁.(自适应)
-
既是轻量级锁也是重量级锁.(自适应)
-
轻量级锁基于自旋锁实现, 重量级锁基于挂起等待锁实现.
-
不是读写锁,是普通的互斥锁
-
是公平锁
-
是可重入锁
synchronized的加锁过程:
-
无锁
即没有出现加锁操作
-
偏向锁(并没有实际进行加锁操作)
线程只是对锁进行了标记,标记了这个锁目前是属于这个线程的, 在没有其他线程来竞争锁的情况下,并不会实际的进行加锁. 当有其他线程来竞争这个锁了,才会实际地进行加锁.(类似于"懒汉模式",只有当需要的时候才加锁)
-
轻量级锁
产生了锁竞争,不激烈会一直保持轻量级锁.
此处的轻量级锁就是通过 CAS 来实现.
-
通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
-
如果更新成功, 则认为加锁成功
-
如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
-
但是这里并不会无限次自旋,如果自旋过多次没有获取到锁,这会膨胀转化为重量级锁.
-
-
重量级锁
锁的竞争比较激烈.
此处的重量级锁就是指用到内核提供的 mutex .
-
执行加锁操作, 先进入内核态.
-
在内核态判定当前锁是否已经被占用
-
如果该锁没有占用, 则加锁成功, 并切换回用户态.
-
如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
-
经历了一系列的等待时间, 这个锁被其他线程释放了, 操作系统也想起了(本质还是调度算法)这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁.
-
其他锁优化:
锁消除:
编译器会只能判定这个代码是否需要加锁:
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
append 方法中内部是带synchronized的,但是如果上述代码在一个线程中执行,那么就会认为本身就无线程安全问题,那么JVM 就会将锁消除.
这个操作在大部分情况下都是不会触发的.能触发的情况比较少.
锁粗化:
锁的粒度:表示synchronized中的代码范围大小,范围越大,则认为锁越粗,范围越小,则认为锁越细
锁的粒度细了,就可以实现更好的线程并发,但是也会带来"加锁次数过多"的问题.
for(int i = 0;i < 1000;i++){
synchronized(locker){
i++;
}
}
synchronized(locker){
for(int i = 0;i < 1000;i++){
i++;
}
}
JUC 中 相关的类
Callable接口:
学会使用Callable接口:
public class demo21 {
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 = 0; i < 1000; i++) {
sum++;
}
return sum;
}
};
//套上一层,目的是为了获取上面的结果
FutureTask<Integer> fask = new FutureTask<Integer>(callable);
//将fask传入线程
Thread t = new Thread(fask);
t.start();
//get方法会在线程执行完毕计算出结果并返回 才会执行
System.out.println(fask.get());
}
}
在这里,callable相当于一个带有返回值的任务,我们在写好这个callable之后,需要给他套上一层FutureTask 这样才能传入线程并获取结果. 对于返回的结果我们可以通过 get() 方法获取.
ReentrantLock
这里主要是可重入锁.
为什么synchronized已经是可重入锁,还要使用ReetrantLock?
-
synchronized只是一个关键字,以代码块对代码进行加锁解锁.
ReentrantLock这是一个类,使用lock 加锁, unlocker解锁.
-
synchronized固定为"非公平锁"
ReentrantLock 提供了一个 “公平锁版本”,可以自由切换"公平锁" 和"非公平锁"
import java.util.concurrent.locks.ReentrantLock; public class demo21 { public static void main(String[] args) { //true 锁为公平锁 //false 锁为非公平锁 ReentrantLock lock = new ReentrantLock(true); try{ lock.lock(); lock.trylock(); //具体代码逻辑.但是这里可能会导致异常,这样就执行不到后面的unlock,所以为了保险,我们需要在finall中执行unlock. }finally { lock.unlock(); } } }
-
synchronized如果加锁失败,则会阻塞等待.
ReentrantLock 提供了两种选择:
lock: 如果加锁失败,则阻塞等待.
unlock: 如果加锁失败,这不会阻塞等待.直接往下执行并且返回false.
-
ReentrantLock 提供了更强大的 等待唤醒机制.
synchronized的wait()和notify()只能随机唤醒其中的一个线程.而ReentrantLock则可以通过Condition类来指定唤醒某一个线程或者随机唤醒.
如何选择这两把锁:
-
锁冲突较小,使用synchronized.
-
锁冲突较大,使用ReentrantLock,ReentrantLock 的 tryLock可以更好地控制加锁的行为,不至于死等.
-
需要使用公平锁就要用ReentrantLock
原子类:
基于CAS实现的原子类:
-
AtomicBoolean
-
AtomicInteger
-
AtomicIntegerArray
-
AtomicLong
-
AtomicReference
-
AtomicStampedReference
以AtomicInteger为例的方法:
addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;
线程池:
这里重点介绍:ThreadPollExecutor类
以最复杂的版本来认识参数:
-
corePoolSize: 核心线程数
-
maximumPoolSize : 最大线程数
在线程池中,分为两类线程:核心线程和临时线程,核心线程无聊是否空闲还是忙碌,都会存在,而临时线程只有在忙碌的时候创建,空闲的时候则会销毁.
-
keepAliveTime : 允许临时线程空闲的最长时间,
-
TimeUnit unit : keepAliveTime的时间单位.
-
BlockingQueue workqueue :
线程池中虽然内置了任务队列,但是我们也可以将自定义的任务队列传入线程池.
-
ThreadFactory threadFactory :
参与具体的线程创建工作.
-
RejectedExecutionHandler handler:
拒绝策略,当线程池的任务队列满了之后,该如何操作:
-
超过负荷,直接抛出异常,停止工作.
-
交给添加任务的调用者处理(即把任务重新扔回去)
-
丢掉任务队列中最老的任务
-
丢掉任务队列中最新的任务
-
对于线程池中的线程数如何确定:
不同的场景需要的线程数是不确定的,如果回答固定的数字,那一定是错误的,我们无法明确我们创建的线程池需要多少个线程,但是我们可以通过恰当的方法来设置线程池的线程数.
进行压测:
针对当前的程序进行性能测试,分别设置不同的线程池的数目,分别进行测试,记录测试过程中的程序的响应时间,CPU占用,内存占用等等…根据实际情况来确定线程池中的核心数量.
对于CPU密集场景: 线程数最多也只能是CPU核心数,就算超过也无意义
对于IO 密集场景: 线程数可以超过CPU核心数,因为 IO 不吃 CPU资源
但是在实际开发中, 一般是CPU与IO并存, 具体得看两者的比例才能确定线程池的核心数.
信号量semaphore
是一个描述可用资源数量的计数器:
申请一个资源: 信号量就-=1 ,为 P操作 (原子操作)
释放一个资源: 信号量就+=1, 为 V操作 (原子操作)
Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.
semaphore可以直接控制多线程线程安全的控制,可以认为是一把更加广义的锁,当信号量的总数为0-1时,则可以认为是一把普通的锁了.
也就是说使用信号量可以实现 “共享锁”, 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待,直到前面的线程执行了 V 操作.
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(3);
//申请一个资源:
semaphore.acquire();
//释放一个资源:
semaphore.release();
}
CountDownLatch
同时等待N个线程结束
-
构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
-
每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.
-
主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(10);
for(int i = 0;i < 10;i++){
Thread t = new Thread(()->{
try {
Thread.sleep(3000);
System.out.println("执行结束");
//相当于撞线
latch.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t.start();
}
latch.await();
System.out.println("全部执行完毕");
}
线程安全的集合类:
多线程下使用Arraylist
-
Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
synchronizedList 的关键操作上都带有 synchronized
-
CopyOnWriteArrayList:
如果出现了修改操作,就立即对顺序表进行拷贝,当新线程修改后再用副本替换.成本开销较大.
多线程下使用队列:
-
ArrayBlockingQueue
基于数组实现的阻塞队列
-
LinkedBlockingQueue
基于链表实现的阻塞队列
-
PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
ConcurrentHashMap详解:
在之前我们已经了解到了 Hashmap 是线程不安全的 , HashTable 是线程安全的.
但是我们之前为什么不推荐使用HashTable?
这是因为HashTable保证线程安全的方式是直接对整个哈希表进行synchronized,这样只能保证多线程下修改不同的哈希桶下的值会线程安全,如果多线程下修改同一个哈希桶下的值,就无法保证线程安全了.
而ConcurrentHashMap对此进行了重大改进:
将锁的粒度细化,在每一个哈希桶上都加锁.
ConcurrentHashMap的优化特点:
-
将锁的粒度细化,每一个哈希桶都加锁,大大地减少了锁冲突的概率.
-
对读没加锁,只对写加锁
-
在维护size 的时候 采用 CAS 特性
-
针对扩容场景进行优化,每一次基本操作只扩容一点,逐渐完成整个扩容操作.在扩容的时候,旧表和新表同时存在,查询的时候旧表和新表一起查询,每一次新增操作,就往新表上新增,直到所有的元素都搬运完,才完成整个扩容.
HashMap,HashTable和ConcurrentHashMap的区别:
-
HashMap是线程不安全的,而HashTable 和 ConcurrentHashMap 是线程安全的
-
HashTable内部使用的是一把大锁,而ConcurrentHashMap将锁的粒度细化,为每个哈希桶都加了锁,极大地降低了锁冲突的概率.
-
扩展讲 : ConcurrentHashMap 的主要优化特点
-
HashMap key允许为null, 其他两个不允许.
死锁问题:
-
1个线程1把锁的情况:
线程A对同一把锁加锁多次,如果该锁是不可重入锁 这会导致死锁问题.
-
2个线程2把锁:
线程1获取锁A
线程2获取锁B
线程1尝试获取锁B
线程2尝试获取锁A
这样线程1无法获取线程2未释放的锁B,线程2无法获取线程1未释放的锁A.造成死锁
-
M个线程 N 把锁:
哲学家问题:
哲学家就餐问题是在计算机科学中的一个经典问题,用来演示在并行计算中多线程同步(Synchronization)时产生的问题。
有五个哲学家,他们共用一张圆桌,分别坐在五张椅子上。在圆桌上有五个碗和五支筷子,平时一个哲学家进行思考,饥饿时便试图取用其左、右最靠近他的筷子,只有在他拿到两支筷子时才能进餐。进餐完毕,放下筷子又继续思考。
当哲学家都同时拿起左边的筷子时,则会发现所有人手中都只有一把筷子,谁都在等别人吃完,但是谁也不能吃.
如果把哲学家比做线程 则认为线程死锁.
解决方案:
对锁进行编号:1 2 3 4…
约定当一个线程需要获取多把锁时,明确获取锁的顺序是从小到大.
对于解决死锁问题,还有个更复杂的"银行家算法".(了解)
死锁的四个必备条件
-
互斥使用: 线程A获取到锁但是不释放,其他线程获取不到
-
不可抢占: 线程A获取到锁,其他线程只能阻塞等待,无法直接抢占锁
-
请求和保持: 当线程A已经获取到锁A时,如果再去请求获取别的资源, 会一直保持"已经获取到锁A"的状态,直到释放锁.
-
循环等待: 存在一个循环队列,使得线程之间同时只能获取一部分锁,导致死锁.
死锁总结:
死锁是线程在获取到锁后,没有及时释放,导致其他线程获取这把锁时获取失败,只能阻塞等待,导致整个程序僵住.
产生死锁的三个典型场景.
死锁的必备条件.
从循环等待切入,理解如何破解哲学家就餐死锁问题.