在开始讲之前,我们先来回顾回顾前三篇所讲过的内容~
线程的概念
并发编程,多进程,比较重,频繁创建销毁,开销大
Thread
的使用
Thread
Runnable
Thread
(匿名内部类)Runnable
(匿名内部类)lambda'
Thread
中的重要性start
isInterrupted() interrupt()
=>本质上是让线程快点执行完入口方法join
a.join()
让调用这个方法的线程等待a
线程的结束线程状态(方便快速判定当前程序执行的情况)
NEW
TERMINATED
RUNNABLE
TIMED_WAITING
WAITING
BLOCKED
线程安全
演示线程不安全的例子:两个线程自增5w次
原因:
解决:加锁 => synchronized
synchronized
修饰的是一个代码块
同时指定一个锁对象
进入代码块的时候,对该对象进行加锁
出了代码块的时候,对该对象进行解锁
锁对象:
锁对象到底用哪个对象是无所谓的,对象是谁不重要;重要的是两线程加锁的对象是否是同一个对象
这里的意义/规则,有且只有一个
当两个线程同时尝试对一个对象加锁,此时就会出现“锁冲突”/“锁竞争”,一旦竞争出现,一个线程能够拿到锁,继续执行代码;一个线程拿不到锁,就只能阻塞等待,等待前一个线程释放锁之后,他才有机会拿到锁,继续执行~
这样的规则,本质上就是把“并发执行” => “串行执行”,这样就不会出现“穿插”的情况了。
续上文最后,synchronized
除了修饰代码块之外,还可以修饰一个实例方法,或者一个静态方法
class Counter{
public int count;
synchronized public void increase(){
count++;
}
public void increase2(){
synchronized (this) {
count++;
}
}
synchronized public static void increase3(){
}
public static void increase4(){
synchronized (Counter.class){
}
}
}
// synchtonized 使用方法
public class Demo14 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
synchronized
用的锁是存在Java
对象头里的。
何为对象头呢?
Java
的一个对象,对应的内存空间中,除了你自己定义的一些属性之外,还有一些自带的属性
在对象头中,其中就会有属性表示当前对象是否已经加锁了
synchronized
的工作过程:
获得互斥锁
从主内存拷贝变量的最新副本到工作的内存
执行代码
将更改后的共享变量的值刷新到主内存
释放互斥锁
但是目前刷新内存这一块知识各种说法都有,目前也难以通过实例验证,pass~
synchronized
:重要的特性,可重入的
所谓的可重入锁,指的就是,一个线程连续针对一把锁,加锁两次,不会出现死锁。满足这个需求就是“可重入锁”,反之就是“不可重入锁”。
下面见图:
上述的现象,很明显就是一个bug
,但是我们在日常开发中,又难以避免出现上述的代码~例如下面这样的案例:
public class Demo15 {
private static Object locker = new Object();
public static void func1(){
synchronized (locker){
func2();
}
}
public static void func2(){
func3();
}
public static void func3(){
func4();
}
public static void func4(){
synchronized (locker){
}
}
public static void main(String[] args) {
}
}
要解决死锁问题,我们可以将synchronized
设计成可重入锁,就可以有效解决上述的死锁问题~
就是让锁记录一下,是哪个线程给它锁住的,后续再加锁的时候,如果加锁线程就是持有锁的线程,就直接加锁成功~
用一个例子来理解:
你向一个哥们表白,我爱你,成功了,他接受你了,也就是你对他加锁成功了,同时他也会记得你就是她的男朋友~
过了几天,你又对他说,宝贝我爱你,这时候的那个哥们当然也不会拒绝,反而会更加基情~
不过要是换成别人,结果肯定就是不一样的(排除绿你的情况~)
这里提出个问题:
synchronized(locker){
synchronized(locker){
........................
}②
}①
synchronized
是可重入锁,没有因为第二次加锁而死锁,但是当代码执行到 }②
,此时锁是否应该释放?**不能!!!**因为如果释放了锁,很可能就会导致②和①之间的一些代码逻辑无法执行,也就起不到锁保护代码的作用了~
无论此处有多少层,都是要在最外层才能释放锁~~
引用计数
锁对象中,不光要记录谁拿到了锁,还要记录,锁被加了几次
每加锁一次,计数器就+1.
每解锁一次,计数器就·1.
出了最后一个大括号,恰好就是减成0了,才真正释放锁
那么上面我们讲解了死锁的一种情况,一个线程针对一把锁,加锁两次。
接下来下面我们继续介绍死锁的情况~
一个线程针对一把锁,加锁两次,如果是不可重入锁,就会死锁~
(synchronized
不会出现,但是隔壁C++的std::mutex
就是不可重入锁,就会出现死锁)
两个线程(t1、t2
),两把锁(A、B
)(此时无论是不是不可重入锁,都会死锁)
举个例子:钥匙锁车里,车钥匙锁家里~
t1
获取锁A,t2
获取锁B
t1
尝试获取B
,t2
尝试获取A
实例代码
// 死锁
public class Demo16 {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
//此处的sleep很重要,要确保 t1 和 t2 都分别拿到一把锁之后,再进行后续动作
public static void main(String[] args) {
Thread t1 = new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2){
System.out.println("t1 加锁成功");
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker1){
System.out.println("t2 加锁成功");
}
}
});
t1.start();
t2.start();
}
}
死锁现象出现
我们可以在jconsole.exe
中看看线程情况~
同时也要注意,死锁代码中
两个synchronized
是嵌套关系,不是并列关系.
嵌套关系说明:是在占用一把锁的前提下,获取另一把锁.(则是可能出现死锁)
并列关系,则是先释放前面的锁,再获取下一把锁.(不会死锁的)
// 死锁 -> 破嵌套
public class Demo17 {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
//此处的sleep很重要,要确保 t1 和 t2 都分别拿到一把锁之后,再进行后续动作
public static void main(String[] args) {
Thread t1 = new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (locker2){
System.out.println("t1 加锁成功");
}
});
Thread t2 = new Thread(()->{
synchronized (locker2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (locker1){
System.out.println("t2 加锁成功");
}
});
t1.start();
t2.start();
}
}
// 破死锁
public class Demo18 {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
//此处的sleep很重要,要确保 t1 和 t2 都分别拿到一把锁之后,再进行后续动作
public static void main(String[] args) {
Thread t1 = new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (locker2){
System.out.println("t1 加锁成功");
}
});
Thread t2 = new Thread(()->{
synchronized (locker1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (locker2){
System.out.println("t2 加锁成功");
}
});
t1.start();
t2.start();
}
}
第一段代码中使用的是同一个对象作为锁,在t1和t2线程中都使用了locker对象作为锁。这样的话,当t1线程获取到锁并休眠时,t2线程就无法获取到锁,导致t2线程一直等待,从而可能引发死锁。
第二段代码中使用了两个不同的对象作为锁,分别是locker1
和locker2
。在t1
线程中先获取locker1
锁,再获取locker2
锁;在t2
线程中先获取locker2
锁,再获取locker1
锁。这样的话,两个线程在互斥的同时也保持了顺序,避免了死锁的发生。
N个线程,M把锁(相当于2的扩充)
此时这个情况,更加容易出现死锁了。
下面给出一个经典例子:哲学家就餐问题
死锁,是属于比较严重的bug
,会直接导致线程卡住,也就无法执行后续的工作了~
那么我们应该怎么避免死锁?
那么首先我们要了解死锁的成因:
互斥使用。(锁的基本特性)
当线程持有一把锁之后,另一个线程也想获取到锁,那么就需要阻塞等待、
不可抢占。(锁的基本特性)
当锁已经被 线程 1 拿到之后,线程 2 只能等 线程 1 主动释放,不可以强行抢过来
请求保持。(代码结构)
一个线程尝试获取多把锁。(先拿到 锁1 之后,再尝试获取 锁2 ,获取的时候, 锁1 不会被释放)
这种也就是典型的吃着碗里的,看着锅里的
public class Demo16 {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
//此处的sleep很重要,要确保 t1 和 t2 都分别拿到一把锁之后,再进行后续动作
public static void main(String[] args) {
Thread t1 = new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2){
System.out.println("t1 加锁成功");
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker1){
System.out.println("t2 加锁成功");
}
}
});
t1.start();
t2.start();
}
}
循环等待 / 环路等待(代码结构)
等待的依赖关系,形成环了~
也即是上面那个例子,钥匙锁车里,车钥匙锁家里
实际上,要想出现死锁,也不是个容易事情
因为得把上面4条都占了.
(不幸的是,1和2是锁本身的特性,只要代码中,把3和4占了,死锁就容易出现了)
所以说,解决死锁,核心就是破坏上述必要条件,死锁就形成不了~
针对上述的四种成因,1
和 2
是破坏不了的,因为synchronized
自带特性,我们是无法干预 滴~
对于3
来说,就是调整代码结构,避免编写“锁嵌套”逻辑
对于4
来说,可以约定加锁的顺序,就可以避免循环等待
所以针对上面的哲学家就餐问题,我们可以采取:针对锁进行编号
比如说约定,加多一把锁的时候,先加编号小的锁,后加编号大的锁(所有线程都要遵守这个规则)
这样的话,循环等待就会被解除,死锁也不会出现了~
回到上述我们讲的synchronized
关键字
在使用规则上,并不复杂,只要抓住一个原则:两个线程针对同一个对象加锁,就会产生锁竞争.
但是在底层原理上,synchronized还有不少值得讨论的地方.接下来会展开讲讲~
至此,多线程(四)讲解到这,接下来会持续更新,敬请期待~