多线程(四)
在开始讲之前,我们先来回顾回顾前三篇所讲过的内容~
-
线程的概念
并发编程,多进程,比较重,频繁创建销毁,开销大
-
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 关键字
- 互斥
- 刷新内存
- 可重入
- 死锁
- 死锁的成因
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
是可重入锁,没有因为第二次加锁而死锁,但是当代码执行到}②
,此时锁是否应该释放?
**不能!!!**因为如果释放了锁,很可能就会导致②和①之间的一些代码逻辑无法执行,也就起不到锁保护代码的作用了~
- 进一步,如果上述的锁有n层,释放时机该怎么判断?
无论此处有多少层,都是要在最外层才能释放锁~~
引用计数
锁对象中,不光要记录谁拿到了锁,还要记录,锁被加了几次
每加锁一次,计数器就+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
是嵌套关系,不是并列关系.
嵌套关系说明:是在占用一把锁的前提下,获取另一把锁.(则是可能出现死锁)
并列关系,则是先释放前面的锁,再获取下一把锁.(不会死锁的) -
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还有不少值得讨论的地方.接下来会展开讲讲~
至此,多线程(四)讲解到这,接下来会持续更新,敬请期待~