目录
前言:
1.常见的锁策略
1.1乐观锁和悲观锁
1.2轻量级锁和重量级锁
1.3自旋锁和挂起等待锁
1.4互斥锁与读写锁
1.5可重入锁与不可重入锁
1.6公平锁与非公平锁
2.CAS
2.1什么是CAS
2.2自旋锁的实现
2.3原子类
3.synchronized
3.1synchronized的原理以及基本特点
3.2偏向锁
3.3轻量级锁
3.4重量级锁
3.5锁消除
3.6锁粗化
4.JUC
4.1JUC中常见到组件
4.1.1callable接口的用法
4.1.2ReentrantLock可重入互斥锁
4.1.3信号量Semaphore
4.1.4CountDownLatch
5.线程安全的集合类
5.1HashTable
5.2ConcurrentHashMap
5.3ConcurrentHashMap与HashMap的区别
6.死锁
6.1什么是死锁
6.2哲学家就餐问题
结束语:
在上一节中小编主要是与大家分享了多线程中的线程池的概念以及使用方式,在之前的多线程博客中也个大家介绍了多线程中的一些基础知识,希望大家下去之后多多练习,巩固一下,那么从这节开始小编就开始给大家介绍多线程中的最后一点知识,虽然这些东西不常使用,但是我们还是需要稍微的理解一下的。话不多说我们直接步入正题吧!
以下介绍的锁策略不只是针对java的,别的语言别的工具也会涉及到锁,也同样适合用。
锁的实现者会预测接下来锁冲突的概率是大还是不大,根据这个冲突的概率来决定接下来该咋做。这里的锁的冲突就是锁竞争,两个线程在针对一个对象加锁,另一个就会产生阻塞等待。
这里我们就根据预测锁冲突的概率大小的问题来将锁分为乐观锁和悲观锁。
通常来说悲观锁做的工作要多一些,但是效率会更低一些,而乐观锁做的工作少一些,但是工作的效率会高一些。
和乐观和悲观虽然不是一回事,但是也有一定的重合,一个乐观锁很有可能也是一个轻量级锁,一个悲观锁很可能也是一个重量级锁。
自旋锁是一种典型的轻量级锁的实现方式。
其中我们之前学习的synchronized即是悲观锁也是乐观锁,即是轻量级锁也是重量级锁,轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待锁实现。
那么究竟什么时候是乐观锁,什么时候是悲观锁,什么时候是轻量级锁,什么时候是重量级的锁,这里synchronized会根据当前锁竞争的激烈程度自适应。如果锁冲突不激烈,就以轻量级/乐观锁的状态运行,如果锁冲突激烈,以重量级锁/悲观锁的状态运行。
在读写锁中约定:
读写锁特别适合于“频繁读,不频繁写”的场景中。
在我们Java里面只要是以Reentrant开头命名的锁都是可重入锁,而且JDK提供所有现成的Lock实现类,包括synchronized关键字都是可重入锁。
注意:
CAS的全称是:Compare and swap,字面意思就是“比较并交换”,一个CAS涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。步骤如下所示:
CAS就相当于是给我们打开了新世界的大门让我们不需要加锁,就能够保证线程的安全。基于CAS可以实现很多操作,具体的我们往下看。
伪代码如下所示:
public class SpinLock {
private Thread owner = null;
public void lock(){
//通过CAS看当前锁是否被某个线程持有。
//如果这个锁已经被别的线程持有,那么就自旋等待。
//如果这个锁没有被别的线程持有,那么就把owner设为当前尝试加锁的线程。
while (!CAS(this.owner,null,Thread.currentThread())){
}
}
public void unlock() {
this.owner = null;
}
}
在上述代码中如果当前owner是null,比较就成功,就把当前线程的引用设置到owner中,加锁完成,循环就会结束,如果比较不成功,意味着owner非空,锁已经有线程持有了,此时CAS就啥也不干,直接返回false,循环继续。此时的循环就会转的飞快,不停的尝试询问这里的锁是不是释放了,它的好处就是一旦释放,就会立即获取到,坏处就是CPU此时就会处于一种忙等的状态。
在标准库中提供了java.util.concurrent.atomic包,里面的都是基于这种方式来实现的,典型的类就是:AtomicInteger类,其中getAndIncrement就相当于是++操作,他就能够保证在++ 和 -- 的时候线程是安全的。
将自增的结果写回到CPU中。
另一个先判断要自增的数据是不是和之前读取到的数据一样,如果不一样则重新读入,再自增。
接下来我们就用代码给大家具体来演示一下:
代码展示:
package Thread;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadTest02 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger num = new AtomicInteger(0);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
//实现自增
num.getAndIncrement();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
//实现自增
num.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
//get来获取到数值
System.out.println(num.get());
}
}
结果展示:
上面也给大家大概的有提到synchronized的基本特点,这里小编再给大家总结一下:
synchronized的特点:
JVM将synchronized锁分为无锁、偏向锁、轻量级锁、重量级锁状态,会根据情况进行依次升级。
synchronized的关键策略:锁升级。
加锁的工作过程如下所示:
下面我们来给大家来分别解释一下上面的锁都是什么。
第一个尝试加锁的线程,优先进入偏向锁的状态。偏向锁不是真的“加锁”,只是给对象头中做一个“偏向锁的标记”,记录这个锁属于哪个线程。如果后续没有其他线程来竞争该锁,那么就不用子啊进行其他操作了(避免了加锁解锁的开销)如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于那哪个线程了,很容易识别当前申请锁的线程是不是之前记录的线程),那就取消原来的偏向锁状态,进入一般的轻量级锁状态。
偏向锁的本质上相当于“延迟加锁”,能不加锁就不加锁,“非必要,不加锁”,尽量避免不必要的加锁开销。但是该做的标记还是得做,否则无法区分何时需要真正加锁。
随着其他线程进入竞争,偏向锁状态被消除,进入轻量级锁状态(自适应的自旋锁),此处的轻量级锁就是通过CAS来实现。
如果竞争进一步激烈,自旋不能快速获取到锁状态,就会膨胀为重量级锁。
锁消除也是“非必要,不加锁”,他是在编译阶段做的优化手段,用来检测当前代码是否是多线程执行/是否有必要加锁,如果不必要,又已经把锁给写了,那么就会在编译阶段中将锁自动去掉。
首先来解释一下什么是锁是粒度,简单来说就是在synchronized中包含代码的多少,如果包含的代码越多,粒度越粗,越少则粒度越细。一般我们在写代码的时候多数情况下是希望粒度更小一点,(串行执行的代码少,并发执行的代码就多,效率就高)。下面我们可以画个图来解释一下。
就比如说在公司中如果你要给领导汇报工作,两种情况:
情况1:
先打电话汇报工作A的进展,挂电话。
再打电话汇报工作B的进展,挂电话。
最后打电话汇报工作C的进展,挂电话。
情况2:
打一个电话汇报工作A、B、C的进展,挂电话。
上述的情况1就相当于粒度细的,情况2相当于是粒度粗的情况。
实际开发过程中,使用细粒度锁,是期望释放锁的时候其他线程能使用锁。但是实际上可能并没有其他线程来抢占这个锁,这种情况下JVM就会自动把锁粗化,避免频繁申请释放锁。
JUC是java.util.concurrent的缩写。下面我来看下在里面都有哪些常用到的组件吧!
Callable是一个interface,相当于把线程封装了一个“返回值”,方便程序猿借助多线程的方式计算结果。
他会让你重写call方法,在上述中泛型的参数是啥,call反回的就是啥。
下面我们来写一个代码,创建一个线程,用这个线程来计算:1 + 2 + 3.....+1000。
代码展示:
package Thread;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class ThreadTest03 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//这里只是创建了一个任务
Callable callable = new Callable() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
return sum;
}
};
//创建完任务之后我们还需要找个人来执行这个任务。
//Thread不能直接传callable,需要再来包装一层。
FutureTask futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
System.out.println(futureTask.get());
}
}
在上述代码中需要我们注意的是FutureTask这个类的使用。
这里面它将我们上述创建的任务传递给futuretask,这里的futuretask 就像是我在吃饭的时候服务员一般会给我们一张小票,然后等待饭做好之后我们在凭借着这张小票来获取我们的饭菜,这里也一样,执行完任务之后,我们就可以凭借着futuretask来获取我们的任务的结果了。这里的futuretask有一个方法是get,通过get方法就可以获取到我们上述任务call方法的返回值了。
在之前我们给大家交代了三种创建多线程的方法:继承自Thread,实现Runable,基于lambda三种方法,这里我们又学习了Callable。
我们之前学习的synchronized关键字是基于代码块的方式来控制加锁解锁的。ReentrantLock则是提供了lock和unlock独立的方法,来进行加锁和解锁的。虽然我们在大部分情况下使用synchronized就足够了,但是此处的ReentrantLock也是一个重要的补充。
synchronized与ReentrantLock的区别:
信号量,用来表示“可用资源的个数”,本质上就是一个计数器。可以把信号量想象成是停车场的展示牌,当前有车位100个,表示有100个可用资源。当有车开进去的时候,就相当于申请一个可用资源,可用车位就-1(这个称为信号量的P操作),当有车开出来的时候,就相当于释放了一个可用资源,可用车位就+1(这个称为信号量的V操作),如果计数器的值已近为0了,还尝试申请资源,就会阻塞等待,直到其他线程释放资源。
Semaphore的PV操作中的加减计数器操作都是原子的,可以子啊多线程环境下直接使用。
CountDownLatch操作就是等待N个任务执行结束。好像跑步比赛,10位选手依次就位,哨声吹响之后才能出发,当所有选手都通过终点线之后,才算比赛结束。
我们之前学习过HashMap,他在多线程的环境下是线程不安全的。这里我们就可以使用HashTable来保证线程的安全性。
不过我们进入HashTable的原码中可以发现他只是在关键方法上加上了synchronized。如下所示:
HashTable是针对整个哈希表进行加锁,任何增删查改操作,都会被触发加锁,也就都会可能有锁竞争。
所以接下来我们还有另一种解决办法就是在使用ConcurrentHashMap。
相比于HashTable作出了一系列的改进和优化,我们这里以Java1.8为例。
ConcurrentHashMap不只是一把锁,它是将每一个链表的头结点作为一把锁,每一次进行操作,都是针对对应的链表的锁进行加锁,操作不同链表就是针对不同的锁加锁,就不会有锁冲突了。
死锁是这样的一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放,由于线程被无限期地阻塞,因此程序不可能正常终止。
关于死锁的情况:
此时就会出现一个经典的问题:哲学家就餐问题。如下所示:
死锁的四个必要条件:
注意:上述的死锁的条件缺一不可!!!
死锁是一个比较严重的bug,那么在实践中我们该如何避免出现死锁呢?
一个简单那有效的方法就是破解循环等待的这个条件。我们可以针对锁进行编号,如果需要同时获取多把锁,约定加锁的顺序,务必是先对小的编号加锁,后对大的编号加锁。
比如上述哲学家就餐问题,我们可以对筷子编号,然后让哲学家每次的取的时候先取编号较小的,然后在取编号大的。
如下所示:
如果从5号滑稽开始拿筷子,此时5号滑稽先拿起4号筷子,然后再拿起5号筷子就餐,然后4号滑稽拿起3号筷子,3号滑稽拿起2号筷子,2号滑稽拿起1号筷子,此时1号滑稽要想拿起筷子就需要等到2号滑稽就餐完释放筷子之后在拿起筷子就餐,所以此时1号滑稽就得阻塞等待。此时就不会造成死锁了。
这节中小编主要是与大家分享了多线程中的最后一些知识点,可能有些还没有给大家讲解清楚,不影响在后期的学习中小编会一一给大家交代的,希望这节对大家深入了解多线程有一定帮助,想要学习的同学记得关注小编和小编一起学习吧!如果文章中有任何错误也欢迎各位大佬及时为小编指点迷津(在此小编先谢过各位大佬啦!)