多线程是Java最基本的一种并发模型,本章我们将详细介绍Java多线程编程。

1.多线程基础

1.1.进程

在计算机中,我们把一个任务称为一个进程,浏览器就是一个进程,视频播放器是另一个进程,类似的,音乐播放器和Word都是进程。
某些进程内部还需要同时执行多个子任务。例如,我们在使用Word时,Word可以让我们一边打字,一边进行拼写检查,同时还可以在后台进行打印,我们把子任务称为线程。
进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。

操作系统调度的最小任务单位其实不是进程,而是线程。
因为同一个应用程序,既可以有多个进程,也可以有多个线程,因此,实现多任务的方法,有以下几种:

1.1.1.多进程模式(每个进程只有一个线程)

1.1.2.多线程模式(一个进程有多个线程)

1.1.3.多进程+多线程模式(复杂度最高)

1.2.进程 vs 线程

进程和线程是包含关系,但是多任务既可以由多进程实现,也可以由单进程内的多线程实现,还可以混合多进程+多线程。
具体采用哪种方式,要考虑到进程和线程的特点。
和多线程相比,多进程的缺点在于:

  • 创建进程比创建线程开销大,尤其是在Windows系统上;
  • 进程间通信比线程间通信要慢,因为线程间通信就是读写同一个变量,速度很快。

而多进程的优点在于:

  • 多进程稳定性比多线程高,因为在多进程的情况下,一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃会直接导致整个进程崩溃。

1.3.多线程

Java语言内置了多线程支持:一个Java程序实际上是一个JVM进程,JVM进程用一个主线程来执行main()方法,在main()方法内部,我们又可以启动多个线程。此外,JVM还有负责垃圾回收的其他工作线程等。
因此,对于大多数Java程序来说,我们说多任务,实际上是说如何使用多线程实现多任务。
和单线程相比,多线程编程的特点在于:多线程经常需要读写共享数据,并且需要同步。例如,播放电影时,就必须由一个线程播放视频,另一个线程播放音频,两个线程需要协调运行,否则画面和声音就不同步。因此,多线程编程的复杂度高,调试更困难。
Java多线程编程的特点又在于:

  • 多线程模型是Java程序最基本的并发模型;
  • 后续读写网络、数据库、Web开发等都依赖Java多线程模型。

因此,必须掌握Java多线程编程才能继续深入学习其他内容。

2.创建新线程

Java语言内置了多线程支持。当Java程序启动的时候,实际上是启动了一个JVM进程,然后,JVM启动主线程来执行main()方法。在main()方法中,我们又可以启动其他线程。
要创建一个新线程非常容易,我们需要实例化一个Thread实例,然后调用它的start()方法:

// 多线程
public class Main {public static void main(String[] args) {Thread t = new Thread();t.start(); // 启动新线程}
}

但是这个线程启动后实际上什么也不做就立刻结束了。我们希望新线程能执行指定的代码,有以下几种方法。

2.1.新线程执行指定代码的方法

2.1.1.在Thread的派生类中,覆写run()

// 多线程
public class Main {public static void main(String[] args) {Thread t = new MyThread();t.start(); // 启动新线程}
}class MyThread extends Thread {@Overridepublic void run() {System.out.println("start new thread!");}
}

执行上述代码,注意到start()方法会在内部自动调用实例的run()方法。

2.1.2.创建Thread实例,传入Runnable实例

public class Main {public static void main(String[] args) {Thread t = new Thread(new MyRunnable());t.start(); // 启动新线程}
}class MyRunnable implements Runnable {@Overridepublic void run() {System.out.println("start new thread!");}
}

或者用Java8引入的lambda语法进一步简写为:

public class Main {public static void main(String[] args) {Thread t = new Thread(() -> {System.out.println("start new thread!");});t.start(); // 启动新线程}
}

2.2.线程执行语句和main()方法执行的区别

有童鞋会问,使用线程执行的打印语句,和直接在main()方法执行有区别吗?
区别大了去了。我们看以下代码:

public class Main {public static void main(String[] args) {System.out.println("main start..."); // main线程Thread t = new Thread() { // main线程public void run() {System.out.println("thread run..."); // t线程System.out.println("thread end."); // t线程}};t.start(); // main线程System.out.println("main end..."); // main线程}
}

我们用蓝色表示主线程,也就是main线程,main线程执行的代码有4行,首先打印main start,然后创建Thread对象,紧接着调用start()启动新线程。当start()方法被调用时,JVM就创建了一个新线程,我们通过实例变量t来表示这个新线程对象,并开始执行。
接着,main线程继续执行打印main end语句,而t线程在main线程执行的同时会并发执行,打印thread run和thread end语句。
当run()方法结束时,新线程就结束了。而main()方法结束时,主线程也结束了。
我们再来看线程的执行顺序:

  1. main线程肯定是先打印main start,再打印main end;
  2. t线程肯定是先打印thread run,再打印thread end。

但是,除了可以肯定,main start会先打印外,main end打印在thread run之前、thread end之后或者之间,都无法确定。因为从t线程开始运行以后,两个线程就开始同时运行了,并且由操作系统调度,程序本身无法确定线程的调度顺序。
要模拟并发执行的效果,我们可以在线程中调用Thread.sleep(),强迫当前线程暂停一段时间:

public class Main {public static void main(String[] args) {System.out.println("main start...");Thread t = new Thread() {public void run() {System.out.println("thread run...");try {Thread.sleep(10);} catch (InterruptedException e) {}System.out.println("thread end.");}};t.start();try {Thread.sleep(20);} catch (InterruptedException e) {}System.out.println("main end...");}
}

sleep()传入的参数是毫秒。调整暂停时间的大小,我们可以看到main线程和t线程执行的先后顺序。
要特别注意:直接调用Thread实例的run()方法是无效的:

public class Main {public static void main(String[] args) {Thread t = new MyThread();t.run();}
}class MyThread extends Thread {public void run() {System.out.println("hello");}
}

直接调用run()方法,相当于调用了一个普通的Java方法,当前线程并没有任何改变,也不会启动新线程。上述代码实际上是在main()方法内部又调用了run()方法,打印hello语句是在main线程中执行的,没有任何新线程被创建。
必须调用Thread实例的start()方法才能启动新线程,如果我们查看Thread类的源代码,会看到start()方法内部调用了一个private native void start0()方法,native修饰符表示这个方法是由JVM虚拟机内部的C代码实现的,不是由Java代码实现的。

2.3.线程的优先级

可以对线程设定优先级,设定优先级的方法是:

Thread.setPriority(int n) // 1~10, 默认值5

优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行。

3.线程的状态

3.1.线程状态

在Java程序中,一个线程对象只能调用一次start()方法启动新线程,并在新线程中执行run()方法。一旦run()方法执行完毕,线程就结束了。因此,Java线程的状态有以下几种:

  • New:新创建的线程,尚未执行;
  • Runnable:运行中的线程,正在执行run()方法的Java代码;
  • Blocked:运行中的线程,因为某些操作被阻塞而挂起;
  • Waiting:运行中的线程,因为某些操作在等待中;
  • Timed Waiting:运行中的线程,因为执行sleep()方法正在计时等待;
  • Terminated:线程已终止,因为run()方法执行完毕。

用一个状态转移图表示如下:

当线程启动后,它可以在Runnable、Blocked、Waiting和Timed Waiting这几个状态之间切换,直到最后变成Terminated状态,线程终止。

3.2.线程终止的原因

  • 线程正常终止:run()方法执行到return语句返回;
  • 线程意外终止:run()方法因为未捕获的异常导致线程终止;
  • 对某个线程的Thread实例调用stop()方法强制终止(强烈不推荐使用)。

一个线程还可以等待另一个线程直到其运行结束。例如,main线程在启动t线程后,可以通过t.join()等待t线程结束后再继续运行:

public class Main {public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {System.out.println("hello");});System.out.println("start");t.start();t.join();System.out.println("end");}
}

当main线程对线程对象t调用join()方法时,主线程将等待变量t表示的线程运行结束,即join就是指等待该线程结束,然后才继续往下执行自身线程。所以,上述代码打印顺序可以肯定是main线程先打印start,t线程再打印hello,main线程最后再打印end。
如果t线程已经结束,对实例t调用join()会立刻返回。此外,join(long)的重载方法也可以指定一个等待时间,超过等待时间后就不再继续等待。

3.3.小结

  1. Java线程对象Thread的状态包括:New、Runnable、Blocked、Waiting、Timed Waiting和Terminated;
  2. 通过对另一个线程对象调用join()方法可以等待其执行结束;
  3. 可以指定等待时间,超过等待时间线程仍然没有结束就不再等待;
  4. 对已经运行结束的线程调用join()方法会立刻返回。

4.中断线程

中断线程就是其他线程给该线程发一个信号,该线程收到信号后结束正在执行的run()方法,使得自身线程能立刻结束运行。

4.1.调用interrupt()方法

中断一个线程非常简单,只需要在其他线程中对目标线程调用interrupt()方法,目标线程需要反复检测自身状态是否是interrupted状态,如果是,就立刻结束运行。
我们还是看示例代码:

public class Main {public static void main(String[] args) throws InterruptedException {Thread t = new MyThread();t.start();Thread.sleep(1000);t.interrupt(); // 中断t线程t.join(); // 等待t线程结束System.out.println("end");}
}class MyThread extends Thread {public void run() {Thread hello = new HelloThread();hello.start(); // 启动hello线程try {hello.join(); // 等待hello线程结束} catch (InterruptedException e) {System.out.println("interrupted!");}hello.interrupt();}
}class HelloThread extends Thread {public void run() {int n = 0;while (!isInterrupted()) {n++;System.out.println(n + " hello!");try {Thread.sleep(100);} catch (InterruptedException e) {break;}}}
}

main线程通过调用t.interrupt()从而通知t线程中断,而此时t线程正位于hello.join()的等待中,此方法会立刻结束等待并抛出InterruptedException。由于我们在t线程中捕获了InterruptedException,因此,就可以准备结束该线程。在t线程结束前,对hello线程也进行了interrupt()调用通知其中断。如果去掉这一行代码,可以发现hello线程仍然会继续运行,且JVM不会退出。

4.2.设置running标志位

另一个常用的中断线程的方法是设置标志位。我们通常会用一个running标志位来标识线程是否应该继续运行,在外部线程中,通过把HelloThread.running置为false,就可以让线程结束:

public class Main {public static void main(String[] args)  throws InterruptedException {HelloThread t = new HelloThread();t.start();Thread.sleep(1);t.running = false; // 标志位置为false}
}class HelloThread extends Thread {public volatile boolean running = true;public void run() {int n = 0;while (running) {n ++;System.out.println(n + " hello!");}System.out.println("end!");}
}

注意到HelloThread的标志位boolean running是一个线程间共享的变量。线程间共享变量需要使用volatile关键字标记,确保每个线程都能读取到更新后的变量值。
为什么要对线程间共享的变量用关键字volatile声明?这涉及到Java的内存模型。在Java虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!

这会导致如果一个线程更新了某个变量,另一个线程读取的值可能还是更新前的。例如,主内存的变量a = true,线程1执行a = false时,它在此刻仅仅是把变量a的副本变成了false,主内存的变量a还是true,在JVM把修改后的a回写到主内存之前,其他线程读取到的a的值仍然是true,这就造成了多线程之间共享的变量不一致。
因此,volatile关键字的目的是告诉虚拟机:

  • 每次访问变量时,总是获取主内存的最新值;
  • 每次修改变量后,立刻回写到主内存。

volatile关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。
如果我们去掉volatile关键字,运行上述程序,发现效果和带volatile差不多,这是因为在x86的架构下,JVM回写主内存的速度非常快,但是,换成ARM的架构,就会有显著的延迟。

4.3.小结

  1. 对目标线程调用interrupt()方法可以请求中断一个线程,目标线程通过检测isInterrupted()标志获取自身是否已中断。如果目标线程处于等待状态,该线程会捕获到InterruptedException;

  2. 目标线程检测到isInterrupted()为true或者捕获了InterruptedException都应该立刻结束自身线程;

  3. 通过标志位判断需要正确使用volatile关键字;

  4. volatile关键字解决了共享变量在线程间的可见性问题。

5.守护线程

5.1.为何需要守护线程

Java程序入口就是由JVM启动main线程,main线程又可以启动其他线程。当所有线程都运行结束时,JVM退出,进程结束。
如果有一个线程没有退出,JVM进程就不会退出。所以,必须保证所有线程都能及时结束。
但是有一种线程的目的就是无限循环,例如,一个定时触发任务的线程:

class TimerThread extends Thread {@Overridepublic void run() {while (true) {System.out.println(LocalTime.now());try {Thread.sleep(1000);} catch (InterruptedException e) {break;}}}
}

如果这个线程不结束,JVM进程就无法结束。问题是,由谁负责结束这个线程?
然而这类线程经常没有负责人来负责结束它们。但是,当其他线程结束时,JVM进程又必须要结束,怎么办?
答案是使用守护线程(Daemon Thread)。

5.2.守护线程的概念及创建

守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。
因此,JVM退出时,不必关心守护线程是否已结束。
如何创建守护线程呢?方法和普通线程一样,只是在调用start()方法前,调用setDaemon(true)把该线程标记为守护线程:

Thread t = new MyThread();
t.setDaemon(true);
t.start();

在守护线程中,编写代码要注意:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。

5.3.小结

  1. 守护线程是为其他线程服务的线程;
  2. 所有非守护线程都执行完毕后,虚拟机退出;
  3. 守护线程不能持有需要关闭的资源(如打开文件等)。

6.线程同步

6.1.线程同步背景

当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。
这个时候,有个单线程模型下不存在的问题就来了:如果多个线程同时读写共享变量,会出现数据不一致的问题。
看个例子:

public class Main {public static void main(String[] args) throws Exception {var add = new AddThread();var dec = new DecThread();add.start();dec.start();add.join();dec.join();System.out.println(Counter.count);}
}class Counter {public static int count = 0;
}class AddThread extends Thread {public void run() {for (int i=0; i<10000; i++) { Counter.count += 1; }}
}class DecThread extends Thread {public void run() {for (int i=0; i<10000; i++) { Counter.count -= 1; }}
}

上面的代码很简单,两个线程同时对一个int变量进行操作,一个加10000次,一个减10000次,最后结果应该是0,但是,每次运行,结果实际上都是不一样的。
这是因为对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。

6.2.synchronized关键字

例如,对于语句:

n = n + 1;

看上去是一行语句,实际上对应了3条指令:

ILOAD
IADD
ISTORE

我们假设n的值是100,如果两个线程同时执行n = n + 1,得到的结果很可能不是102,而是101,原因在于:

如果线程1在执行ILOAD后被操作系统中断,此刻如果线程2被调度执行,它执行ILOAD后获取的值仍然是100,最终结果被两个线程的ISTORE写入后变成了101,而不是期待的102。
这说明多线程模型下,要保证逻辑正确,对共享变量进行读写时,必须保证一组指令以原子方式执行:即某一个线程执行时,其他线程必须等待:

通过加锁和解锁的操作,就能保证3条指令总是在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。
可见,保证一段代码的原子性就是通过加锁和解锁实现的。Java程序使用synchronized关键字对一个对象进行加锁:

synchronized(lock) {n = n + 1;
}

synchronized保证了代码块在任意时刻最多只有一个线程能执行。我们把上面的代码用synchronized改写如下:

public class Main {public static void main(String[] args) throws Exception {var add = new AddThread();var dec = new DecThread();add.start();dec.start();add.join();dec.join();System.out.println(Counter.count);}
}class Counter {public static final Object lock = new Object();public static int count = 0;
}class AddThread extends Thread {public void run() {for (int i = 0; i < 10000; i++) {synchronized(Counter.lock) {Counter.count += 1;}}}
}class DecThread extends Thread {public void run() {for (int i = 0; i < 10000; i++) {synchronized(Counter.lock) {Counter.count -= 1;}}}
}

注意到代码:

synchronized(Counter.lock) { // 获取锁...
} // 释放锁

它表示用Counter.lock实例作为锁,两个线程在执行各自的synchronized(Counter.lock) { … }代码块时,必须先获得锁,才能进入代码块进行。执行结束后,在synchronized语句块结束会自动释放锁。这样一来,对Counter.count变量进行读写就不可能同时进行。上述代码无论运行多少次,最终结果都是0。
使用synchronized解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。因为synchronized代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以,synchronized会降低程序的执行效率。

6.3.如何使用synchronized

  1. 找出修改共享变量的线程代码块;
  2. 选择一个共享实例作为锁;
  3. 使用synchronized(lockObject) { … };

在使用synchronized的时候,不必担心抛出异常。因为无论是否有异常,都会在synchronized结束处正确释放锁:

public void add(int m) {synchronized (obj) {if (m < 0) {throw new RuntimeException();}this.value += m;} // 无论有无异常,都会在此释放锁
}

因此,使用synchronized的时候,获取到的是哪个锁非常重要。锁对象如果不对,代码逻辑就不对。

6.4.不需要synchronized的操作

JVM规范定义了几种原子操作:

  1. 基本类型(long和double除外)赋值,例如:int n = m;
  2. 引用类型赋值,例如:List<String> list = anotherList;

long和double是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把long和double的赋值作为原子操作实现的。
单条原子操作的语句不需要同步。例如:

public void set(int m) {synchronized(lock) {this.value = m;}
}

就不需要同步。
对引用也是类似。例如:

public void set(String s) {this.value = s;
}

上述赋值语句并不需要同步。
但是,如果是多行赋值语句,就必须保证是同步操作,例如:

class Pair {int first;int last;public void set(int first, int last) {synchronized(this) {this.first = first;this.last = last;}}
}

有些时候,通过一些巧妙的转换,可以把非原子操作变为原子操作。例如,上述代码如果改造成:

class Pair {int[] pair;public void set(int first, int last) {int[] ps = new int[] { first, last };this.pair = ps;}
}

就不再需要同步,因为this.pair = ps是引用赋值的原子操作。而语句:

int[] ps = new int[] { first, last };

这里的ps是方法内部定义的局部变量,每个线程都会有各自的局部变量,互不影响,并且互不可见,并不需要同步。

6.5.小结

  1. 多线程同时读写共享变量时,会造成逻辑错误,因此需要通过synchronized同步;
  2. 同步的本质就是给指定对象加锁,加锁后才能继续执行后续代码;
  3. 注意加锁对象必须是同一个实例;
  4. 对JVM定义的单个原子操作不需要同步。

7.同步方法

7.1.对synchronized的逻辑封装

我们知道Java程序依靠synchronized对线程进行同步,使用synchronized的时候,锁住的是哪个对象非常重要。
让线程自己选择锁对象往往会使得代码逻辑混乱,也不利于封装。更好的方法是把synchronized逻辑封装起来。例如,我们编写一个计数器如下:

public class Counter {private int count = 0;public void add(int n) {synchronized(this) {count += n;}}public void dec(int n) {synchronized(this) {count -= n;}}public int get() {return count;}
}

这样一来,线程调用add()、dec()方法时,它不必关心同步逻辑,因为synchronized代码块在add()、dec()方法内部。并且,我们注意到,synchronized锁住的对象是this,即当前实例,这又使得创建多个Counter实例的时候,它们之间互不影响,可以并发执行:

var c1 = Counter();
var c2 = Counter();// 对c1进行操作的线程:
new Thread(() -> {c1.add();
}).start();
new Thread(() -> {c1.dec();
}).start();// 对c2进行操作的线程:
new Thread(() -> {c2.add();
}).start();
new Thread(() -> {c2.dec();
}).start();

现在,对于Counter类,多线程可以正确调用。

7.2.线程安全(thread-safe)

如果一个类被设计为允许多线程正确访问,我们就说这个类就是“线程安全”的(thread-safe),上面的Counter类就是线程安全的。Java标准库的java.lang.StringBuffer也是线程安全的。
还有一些不变类,例如String,Integer,LocalDate,它们的所有成员变量都是final,多线程同时访问时只能读不能写,这些不变类也是线程安全的。
最后,类似Math这些只提供静态方法,没有成员变量的类,也是线程安全的。
除了上述几种少数情况,大部分类,例如ArrayList,都是非线程安全的类,我们不能在多线程中修改它们。但是,如果所有线程都只读取,不写入,那么ArrayList是可以安全地在线程间共享的。
没有特殊说明时,一个类默认是非线程安全的。
我们再观察Counter的代码:

public class Counter {public void add(int n) {synchronized(this) {count += n;}}...
}

当我们锁住的是this实例时,实际上可以用synchronized修饰这个方法。下面两种写法是等价的:

public void add(int n) {synchronized(this) { // 锁住thiscount += n;} // 解锁
}
public synchronized void add(int n) { // 锁住thiscount += n;
} // 解锁

因此,用synchronized修饰的方法就是同步方法,它表示整个方法都必须用this实例加锁。

7.3.synchronized修饰的static方法

我们再思考一下,如果对一个静态方法添加synchronized修饰符,它锁住的是哪个对象?

public synchronized static void test(int n) {...
}

对于static方法,是没有this实例的,因为static方法是针对类而不是实例。但是我们注意到任何一个类都有一个由JVM自动创建的Class实例,因此,对static方法添加synchronized,锁住的是该类的class实例。上述synchronized static方法实际上相当于:

public class Counter {public static void test(int n) {synchronized(Counter.class) {...}}
}

我们再考察Counter的get()方法:

public class Counter {private int count;public int get() {return count;}...
}

它没有同步,因为读一个int变量不需要同步。
然而,如果我们把代码稍微改一下,返回一个包含两个int的对象:

public class Counter {private int first;private int last;public Pair get() {Pair p = new Pair();p.first = first;p.last = last;return p;}...
}

就必须要同步了。

7.4.小结

  1. 用synchronized修饰方法可以把整个方法变为同步代码块,synchronized方法加锁对象是this;
  2. 通过合理的设计和数据封装可以让一个类变为“线程安全”;
  3. 一个类没有特殊说明,默认不是thread-safe;
  4. 多线程能否安全访问某个非线程安全的实例,需要具体问题具体分析。

8.死锁

8.1.Java的线程锁是可重入的锁。

什么是可重入的锁?我们还是来看例子:

public class Counter {private int count = 0;public synchronized void add(int n) {if (n < 0) {dec(-n);} else {count += n;}}public synchronized void dec(int n) {count += n;}
}

观察synchronized修饰的add()方法,一旦线程执行到add()方法内部,说明它已经获取了当前实例的this锁。如果传入的n < 0,将在add()方法内部调用dec()方法。由于dec()方法也需要获取this锁,现在问题来了:
对同一个线程,能否在获取到锁以后继续获取同一个锁?
答案是肯定的。JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁。
由于Java的线程锁是可重入锁,所以,获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录+1,每退出synchronized块,记录-1,减到0的时候,才会真正释放锁。

8.2.死锁

一个线程可以获取一个锁后,再继续获取另一个锁。例如:

public void add(int m) {synchronized(lockA) { // 获得lockA的锁this.value += m;synchronized(lockB) { // 获得lockB的锁this.another += m;} // 释放lockB的锁} // 释放lockA的锁
}public void dec(int m) {synchronized(lockB) { // 获得lockB的锁this.another -= m;synchronized(lockA) { // 获得lockA的锁this.value -= m;} // 释放lockA的锁} // 释放lockB的锁
}

在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。对于上述代码,线程1和线程2如果分别执行add()和dec()方法时:

  • 线程1:进入add(),获得lockA;
  • 线程2:进入dec(),获得lockB。

随后:

  • 线程1:准备获得lockB,失败,等待中;
  • 线程2:准备获得lockA,失败,等待中。

此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。
死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。
因此,在编写多线程应用时,要特别注意防止死锁。因为死锁一旦形成,就只能强制结束进程。
那么我们应该如何避免死锁呢?答案是:线程获取锁的顺序要一致。即严格按照先获取lockA,再获取lockB的顺序,改写dec()方法如下:

public void dec(int m) {synchronized(lockA) { // 获得lockA的锁this.value -= m;synchronized(lockB) { // 获得lockB的锁this.another -= m;} // 释放lockB的锁} // 释放lockA的锁
}

8.3.小结

  1. Java的synchronized锁是可重入锁;
  2. 死锁产生的条件是多线程各自持有不同的锁,并互相试图获取对方已持有的锁,导致无限等待;
  3. 避免死锁的方法是多线程获取锁的顺序要一致。

【廖雪峰官方网站/Java教程】多线程(1)相关推荐

  1. 【廖雪峰官方网站/Java教程】Maven基础

    Maven是一个Java项目管理和构建工具,它可以定义项目结构.项目依赖,并使用统一的方式进行自动化构建,是Java项目不可缺少的工具. 1.Maven介绍 1.1.Maven功能及项目结构 1.1. ...

  2. 【廖雪峰官方网站/Java教程】多线程(3)

    1.使用线程池 1.1.ExecutorService介绍 Java语言虽然内置了多线程支持,启动一个新线程非常方便,但是,创建线程需要操作系统资源(线程资源,栈空间等),频繁创建和销毁大量线程需要消 ...

  3. 【廖雪峰官方网站/Java教程】多线程(2)

    1.使用wait和notify 1.1.多线程协调 在Java程序中,synchronized解决了多线程竞争的问题.例如,对于一个任务管理器,多个线程同时往队列中添加任务,可以用synchroniz ...

  4. 【廖雪峰官方网站/Java教程】函数式编程

    本博客是函数式编程这一节的学习笔记,网址:https://www.liaoxuefeng.com/wiki/1252599548343744/1255943847278976 这一节课内容分为3个主题 ...

  5. 【廖雪峰官方网站/Java教程】设计模式(一)

    0.概述.设计模式的基本概念及原则 设计模式,即Design Patterns,是指在软件设计中,被反复使用的一种代码设计经验.使用设计模式的目的是为了可重用代码,提高代码的可扩展性和可维护性. 为什 ...

  6. 【廖雪峰官方网站/Java教程】设计模式(二)

    0.结构型模式 结构型模式主要涉及如何组合各种对象以便获得更好.更灵活的结构.虽然面向对象的继承机制提供了最基本的子类扩展父类的功能,但结构型模式不仅仅简单地使用继承,而更多地通过组合与运行期的动态组 ...

  7. 【廖雪峰官方网站/Java教程】泛型

    泛型是一种"代码模板",可以用一套代码套用各种类型. 1.什么是泛型 1.1.泛型入门概念介绍 为了在ArrayList中兼容不同类型等元素,我们必须把ArrayList变成一种模 ...

  8. 【廖雪峰官方网站/Java教程】注解

    本节我们将介绍Java程序的一种特殊"注释"--注解(Annotation). 1.使用注解 1.1.注解入门示例 什么是注解(Annotation)?注解是放在Java源码的类. ...

  9. 【廖雪峰官方网站/Java教程】反射

    注:本文参考自:https://www.liaoxuefeng.com/wiki/1252599548343744/1255945147512512 什么是反射? 反射就是Reflection,Jav ...

最新文章

  1. R语言数据包自带数据集之survival包的lung数据集字段解释、数据导入实战
  2. python3 实现 php bin2hex 函数
  3. 一个App完成入门篇(一)-从Hello world开始
  4. ORACLE锁学习总结
  5. 正确获取硬盘序列号源码
  6. 不打游戏还整个i7 8700的弊端,完全用不上
  7. linux限制组访问权限,linux用户和组管理以及文件权限访问控制ACL策略
  8. BZOJ 2956 模积和
  9. CSS3最颠覆性的动画效果,基本属性[过渡和2D]
  10. 详解《云原生架构白皮书》,附下载链接
  11. matlab正弦波占空比怎么调,matlab实现可调节占空比的方波
  12. 拆分pdf文件最简单的方法
  13. 孙鑫视频VC++深入详解学习笔记
  14. 海量数据大课学习笔记(2)-不在其位要谋其政,技术Leader能力模型提升-小滴课堂
  15. OpenCV的Masking操作
  16. 计算机与信息科学书刊,第五届信息科学、计算机技术与交通运输国际学术会议(ISCTT 2020)...
  17. STAR软件安装以及使用
  18. 生成符合SCI论文投稿要求的高清图方法
  19. 百度超级链作为专班核心成员 参与北京市政务区块链顶层设计
  20. SOD算法:PoolNet

热门文章

  1. Flex全局错误处理/Global Error Handling in AIR 2.0 and Flash 10.1
  2. 数据结构上机实践第14周项目1(2) - 验证算法(分块查找)
  3. stl之queue队列容器
  4. oracle 包含的对象,oracle – 我可以创建一个包含嵌套表作为属性的对象表吗?
  5. c语言小学生四则运算出题_求助 设计一个小学生自助四则运算练习软件
  6. 百度竞价排名曝光_企业入驻百度爱采购必须选好本地运营服务商
  7. 以未来式计算机为题目的作文,一般将来时:my travel plan为题作文
  8. sql顶部菜单项消失_SQL选择顶部
  9. log4j:warn找不到_修复log4j WARN找不到记录器的附加程序,请正确初始化log4j系统
  10. pytorch入门_PyTorch入门