我们已经知道,在多线程中抢占式执行,随机调度是一个非常麻烦的问题,将代码中的操作原子 (也就是加锁) 化是解决这个问题的一个方法。
一. synchronized 中的使用方法
synchronized 会起到互斥效果,如果两个线程同时尝试加锁,此时一个线程可以获取锁成功,另一个线程只能阻塞等待(BLOCKED),一直阻塞到刚才的线程释放锁,当前线程才能进行加锁。
- 进入synchronized 修饰的代码块,相当于 加锁。
- 退出 synchronized 修饰的代码块,相当于 解锁。
下面我通过代码来解释一下加锁是如何作用的。
让一个数字增加 10 万。
代码示例1:
这里是没有加锁的代码
class Counter{
public int count = 0;
//对方法不使用 synchronized 进行加锁
public void increase(){
count++;
}
}
public class ThreadDemo {
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);
}
}
运行结果:
很显然结果并不符合预期。
示例代码2:
这里对内部方法进行加锁
class Counter{
public int count = 0;
//对内部方法进行 synchronized 加锁
synchronized public void increase(){
count++;
}
}
public class ThreadDemo {
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 修饰代码分为下面几种:
- 修饰方法(普通方法)
如图所示:
- 修饰方法(静态方法)
- 修饰代码块
也就是手动去指定将锁加到那个对象上
如图所示:
- 锁类对象
二. synchronized 的可重入性
对于可重入,简单理解就是:一个线程针对同一个对象,连续加锁两次,如果没有问题,就表明是可重入的。
按照前面对锁的设定,第二次加锁的时候就会阻塞等待。直到第一次锁被释放,才能获取到第二个锁。但是,当线程出现下面的情况,如图:
这明显是第一次锁没有被释放,后面的线程也就不能进入。
这种情况被称为 死锁。
这样的锁,即就是加锁后不进行释放的锁,称为不可重入锁。
代码示例
class Counter{
public int count = 0;
//这里首先对方法进行了加锁
synchronized public void increase(){
//这里的 this 可以是任意想指定的对象
//这里又一次对方法中的元素进行了加锁
synchronized (this){
count++;
}
}
}
public class ThreadDemo14 {
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 关键字不会有上面的问题,因此它是一个可重入锁。
三.什么是(synchronized的互斥)死锁
-
一个线程,一把锁,连续加锁两次。当锁不是可重入锁是,就会发生死锁。
-
两个线程两把锁,t1 线程 t2 线程相互针对,分别对 A 和 B 进行加锁,之后再尝试获取对方的锁。
简单的图示如下:
这样对方获取的都是被加锁后的操作,自然程序就会僵死在原地。
下面,我通过代码进行举例,通过 JDK 自带的工具包来观察这里的死锁到底是怎么一回事。
代码演示:
public static void main(String[] args) {
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
//先让 t1 线程对 A 进行加锁
synchronized (A){
//通过等待,先让两个线程拿到各自的元素
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B){
System.out.println("t1 线程将 A 和 B 都获得了");
}
}
});
Thread t2 = new Thread(()->{
//先让 t2 线程对 B 进行加锁
synchronized (B){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (A){
System.out.println("t2 线程将 A 和 B 都获得了");
}
}
});
t1.start();
t2.start();
}
运行结果:
运行后发现,不会出现任何日志,这就表明了没有线程拿到了两个元素。
之后,打开 jconsole 找到我们的进程名,如图:
双击打开后找到 “线程” 栏并点击。
如上图所示,我们可以通过 JDK 的工具包发现,代码的死锁就是出现在想要获取对方加锁后的元素。
- 多个线程多把锁 (相较于情况 2 更加一般的情况)
书本上有一个非常经典的案例,哲学家就餐问题。
如上图所示,有 5 位哲学家围坐在桌子周围,每两个人之间有一根筷子,规则如下:
- 每个哲学家有两种状态。
1.思考问题(即就是阻塞状态)
2.拿起筷子吃面(即线程获取到锁进行计算) - 想要吃面,就需要拿起左手和右手的两根筷子
我们知道,由于操作系统的随机调度,这 5 位哲学家随时都有可能想吃面条,也随时可能要思考人生。
这里我们假设一种极端情况:同一时刻,所有的哲学家同时拿起左手的筷子,
这时就会出现所有的哲学家都拿不起右手的筷子,都需要等待右边的哲学家将筷子放下,这时就产生了死锁。
要解决这里的死锁问题,其实很简单,只需要设定一个规则,将筷子从大到小编号,让每位哲学家先拿编号小的,后拿编号打大的,这样,问题就迎刃而解了。
到这里,我们最常见的死锁情况基本介绍结束了,但是实际的情况往往更加复杂,因此,了解死锁的形成条件也是我们认识死锁的关键所在。
四.死锁的四个必要条件
- 互斥使用
线程 1 拿到锁,线程 2 就需要等待。 - 不可抢占
线程 1 拿到锁后,必须由线程 1 主动释放。线程 2 不可强行获取。 - 请求和保持
线程 1 拿到锁 A 后,在尝试获取锁 B ,A 的锁仍然保持。 - 循环等待
线程 1 尝试获取到锁 A 和锁 B
线程 2 尝试获取到锁 B 和锁 A
线程 1 在获取锁 B 时等待线程 2 释放 B;线程 2 在获取锁 A 时等待线程 1 释放 A。
上述的四个死锁的必要条件缺一不可,在 synchronized 关键字中,前 3 点是锁自身的特性,无法修改,关键的一点就在第 4 点。
对于第 4 点的解决办法,前面的哲学家问题就给出了很好的答案。
对锁进行编号,指定固定顺序进行加锁!