1 多线程并发安全(续)
1.1 synchronized方法
1.1.1 synchronized方法
与同步代码块不同,同步方法将子线程要访问的代码放到一个方法中,在该方法的名称前面加上关键字synchronized即可,这里默认的锁为this,即当前对象。在使用时,需要确认多线程访问的是同一个实例的同步方法,才能实现同步效果。
同步方法的语法为:
访问修饰符 synchronized 返回类型 方法名(){
}
synchronized也可以用来修饰静态方法,即静态同步方法,此时锁定的是类对象。每个类都有唯一的一个类对象,可以通过类名.class获取。静态同步方法的语法为:
访问修饰符 synchronized static 返回类型 方法名(){
}
由于同步方法和静态同步方法均没有在代码中显式指定使用的锁对象,在实际使用中需要特别注意,仅在锁对象相同时,才能实现线程互斥。
1.1.2 【案例】synchronized方法示例
编写代码,测试synchronized方法。代码示意如下:
import java.util.concurrent.TimeUnit;
public class SynchronizedDemo2 {
public static void main(String[] args) {
MyRun1 run1 = new MyRun1();
Thread t1 = new Thread(run1, "t1");
Thread t2 = new Thread(run1, "t2");
t1.start();
t2.start();
}
}
class MyRun1 implements Runnable {
int num = 0;
// 同步方法,本案例中的锁对象为main方法中的run1
public synchronized void printNum() {
// 在同步代码块中增加一次确认
if (num > 10){
return;
}
String name = Thread.currentThread().getName();
System.out.println(name + ": " + num);
num+=1;
try {
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
@Override
public void run() {
while (num<11){
printNum();
}
}
}
1.1.3 synchronized实现原理
synchronized是通过对象的锁(也称为监视器monitor)来实现的。在Java中,任何一个对象都有一个Monitor与之关联,并提供了获取一个对象的监视器和释放一个对象的监视器的方法。
1、当一个线程想要进入synchronized代码块时,会先申请持有目标对象的锁;
2、如果该线程申请成功,则进入synchronized代码块并执行其中的内容;
3、此时如果其他线程想要进入synchronized代码块,会因无法持有目标对象的锁而进入阻塞状态;
4、当第一个线程执行完synchronized代码块中的内容时,会退出synchronized代码块并释放目标对象的锁;
5、之前申请该对象的锁的所有线程会争抢该锁,得到锁的线程结束阻塞进入synchronized代码块,其他线程继续保持阻塞状态。
整个过程如下图所示:
1.2 死锁
1.2.1 什么是死锁
死锁是指多个线程因竞争资源而造成的一种僵局(互相等待),如果无外力作用,那么这些线程都将无法向前推进。线程死锁的示意如下图所示:
1.2.2 产生死锁的原因
死锁主要是由以下4个因素造成:
1、互斥条件:是指线程对已经获取到的资源进行排他性使用,即该资源同时只由一个线程占用。
2、不可被剥夺条件:是指线程获取到的资源在自己使用完之前不能被其他线程抢占。
3、请求并持有条件:是指一个线程已经持有了至少一个资源,但又提出了新的资源请求,而新资源已被其他线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自己已经获取的资源。
4、环路等待条件:是指在发生死锁时,必然存在一个(线程 — 资源)环形链,即线程集合 {T0,T1,T2,…,Tn}中的T0正在等待一个T1占用的资源,T1正在等待T2占用的资源,依次类推Tn正在等待已被T0占用的资源。环路等待的示意如下图所示:
1.2.3 【案例】死锁示例
编写代码,测试死锁。代码示意如下:
public class DeadLockDemo {
public static void main(String[] args) {
DeadDemo td1 = new DeadDemo();
DeadDemo td2 = new DeadDemo();
td1.flag = 1;
td2.flag = 0;
new Thread(td1,"td1").start();
new Thread(td2,"td2").start();
}
}
class DeadDemo implements Runnable {
public int flag = 1;
// 静态对象是类的所有对象共享的
private static Object o1 = new Object(), o2 = new Object();
@Override
public void run() {
String threadName = Thread.currentThread().getName();
System.out.println(threadName+":flag = "+flag);
if(flag == 1){
synchronized (o1){
System.out.println(threadName+":取得o1锁");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(threadName+":申请o2锁");
synchronized (o2){
System.out.println("1");
}
}
}
if(flag == 0){
synchronized (o2){
System.out.println(threadName+":取得o2锁");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(threadName+":申请o1锁");
synchronized (o1){
System.out.println("0");
}
}
}
}
}
1.3 API的线程安全
1.3.1 API的线程安全概述
Java API的线程安全问题指的是在多线程环境下,使用Java标准库(Java API)中的一些类和方法可能会出现并发问题,导致程序运行出现不确定的结果或者抛出异常。
这并不是Java的设计问题,而是出于对效率和安全的考虑,Java提供了两类API:非线程安全API和线程安全API。
非线程安全API和线程安全API在功能和使用上往往非常相似,主要的区别是内部是否添加了保证线程安全的机制。开发者需要熟知API的线程安全性,并能够根据实际的场景进行正确的选择。
1.3.2 【案例】StringBuilder线程安全问题示例
StringBuilder是非线程安全的,以下通过一个案例演示它可能出现的问题。
import java.util.ArrayList;
import java.util.List;
public class StringBuilderDemo {
public static void main(String[] args) {
StringBuilder builder = new StringBuilder();
int numThreads = 3;
Runnable appendTask = () -> {
for (int i = 0; i < 10000; i++) {
builder.append("A");
}
};
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < numThreads; i++) {
Thread thread = new Thread(appendTask);
threads.add(thread);
thread.start();
}
// 等待所有线程完成
for (Thread thread : threads) {
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("Final length of StringBuilder: " + builder.length());
}
}
1.3.3 StringBuffer
StringBuffer是Java中用于处理可变字符串的类,与StringBuilder非常相似。它们都继承自 AbstractStringBuilder,支持修改字符串内容,可以进行增删改查操作。
与StringBuilder不同的是,StringBuffer 是线程安全的。
StringBuffer 的关键方法都使用了 synchronized 关键字进行同步控制,确保在多线程环境下多个线程可以同时访问和修改同一个 StringBuffer 对象,而不会出现数据不一致或并发问题。
由于 StringBuffer 需要进行同步控制,使得它在性能上较 StringBuilder 稍有劣势。如果不需要考虑线程安全问题,推荐使用 StringBuilder,因为它没有线程安全的开销,性能更高。
如果需要保证多个线程安全地访问和修改同一个字符串缓冲区,应该使用 StringBuffer。
1.3.4 【案例】StringBuffer示例
import java.util.ArrayList;
import java.util.List;
public class StringBufferDemo {
public static void main(String[] args) {
StringBuffer buffer = new StringBuffer();
int numThreads = 3;
Runnable appendTask = () -> {
for (int i = 0; i < 10000; i++) {
buffer.append("A");
}
};
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < numThreads; i++) {
Thread thread = new Thread(appendTask);
threads.add(thread);
thread.start();
}
// 等待所有线程完成
for (Thread thread : threads) {
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("Final length of StringBuilder: " + buffer.length());
}
}
1.3.5 集合的线程安全概述
在 Java 中,集合类主要分为两类:线程安全的集合和非线程安全的集合。线程安全的集合是指在多线程环境下,多个线程可以同时访问和修改集合,而不会出现数据不一致或并发问题。非线程安全的集合是指在多线程环境下,多个线程同时修改集合可能会导致数据不一致或其他并发问题。
Java 中许多集合类都是非线程安全的,例如:ArrayList、LinkedList、HashSet、HashMap。
相应的,Java在java.util.concurrent 包下提供了一些专门设计用于多线程环境的线程安全集合,例如:ConcurrentHashMap、CopyOnWriteArrayList、CopyOnWriteArraySet等。
这部分内容将在后续的课程中展开介绍。
2 内存模型与并发问题
2.1 Java内存模型基础
2.1.1 Java内存模型的抽象结构
在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量(Local Variables),方法定义参数(Formal Method Parameters)和异常处理器参数(Exception Handler Parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
Java线程之间的通信由Java内存模型(Java Memory Model, JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程已读/写共享变量的副本。
本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
假设有线程A和B:
线程A与线程B之间想要通信,必须经历下面2个步骤:
1、线程A把本地内存A中更新过的共享变量刷新到主内存中
2、线程B到内存中去读取线程A之前已更新过的共享变量
从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java开发者提供内存可见性保证。
2.1.2 共享内存的并发问题
线程本地内存和主内存的设计可能带来并发问题。默认情况下,一个线程对主内存中数据的更新并不会通知另一个线程,另一个线程可能基于本地内存中之前缓存的数据进行操作,造成并发问题。如下图所示:
2.1.3【案例】共享内存并发问题示例
编写代码,测试共享内存的并发问题。代码示意如下:
public class SharedDataDemo1 {
public static void main(String[] args) {
// 创建保存共享数据的对象
SharedData sharedData = new SharedData();
// 启动一个线程修改sharedData对象的变量flag,将变量flag改为false
new Thread(new Runnable() {
@Override
public void run() {
String name = Thread.currentThread().getName();
System.out.println("线程" + name + "正在执行");
try {
Thread.sleep(3000);
} catch (Exception e) {
e.printStackTrace();
}
sharedData.setFlagFalse();
System.out.println("线程" + name + "更新后,flag的值为"+
sharedData.flag);
}
}
).start();
// 确定主线程的副本是否会自动更新
while (sharedData.flag) {
// 当上面的线程将变量flag改为false后
// 如果没有自动更新,就会一直在循环中执行
}
System.out.println("主线程运行终止");
}
}
class SharedData {
boolean flag = true;
// 将变量flag的值改为false
public void setFlagFalse(){
this.flag = false;
}
}
2.2 volatile关键字
2.2.1 volatile关键字概述
volatile关键字可以用来修饰字段(成员变量),即规定线程对该变量的访问均需要从共享内存中获取,对该变量的修改也必须同步刷新到共享内存中,以保证资源的可见性。
针对上一个案例的改变,如下图所示:
2.2.2【案例】volatile示例
编写代码,使用volatile关键字解决共享内存的并发问题。代码示意如下:
public class SharedDataDemo2 {
public static void main(String[] args) {
// 创建保存共享数据的对象
SharedData2 sharedData = new SharedData2();
// 启动一个线程修改sharedData对象的变量flag,将变量flag改为false
new Thread(new Runnable() {
@Override
public void run() {
String name = Thread.currentThread().getName();
System.out.println("线程" + name + "正在执行");
try {
Thread.sleep(3000);
} catch (Exception e) {
e.printStackTrace();
}
sharedData.setFlagFalse();
System.out.println("线程" + name + "更新后,flag的值为"+
sharedData.flag);
}
}
).start();
// 确定主线程的副本是否会自动更新
while (sharedData.flag) {
// 当上面的线程将变量flag改为false后
// 如果没有自动更新,就会一直在循环中执行
}
System.out.println("主线程运行终止");
}
}
class SharedData2 {
// 使用关键字volatile修饰变量flag
volatile boolean flag = true;
// 将变量flag的值改为false
public void setFlagFalse(){
this.flag = false;
}
}
3 多线程协作
3.1 多线程协作概述
3.1.1 狭义的线程同步
广义的线程同步被定义为一种机制,用于确保两个或多个并发的线程不会同时进入临界区。从该定义来看,线程同步和线程互斥是相同的。
狭义的线程同步在线程互斥的基础上增加了对多个线程执行顺序的要求,即两个或多个并发的线程应按照特定的顺序进入临界区。
可以简单地总结为,狭义的线程同步是一种强调执行顺序的线程互斥,也称为多线程协作。
例如,在多个线程输出1-10案例中,仅要求同一时间仅能有一个线程执行printNum方法,即线程互斥,如果在案例中要求两个线程必须交替打印数字,不能出现一个线程连续打印连个数字的情况,就属于多线程协作的范畴。
3.1.2 为什么需要多线程协作
在现实生产中,我们经常会遇到多个人分工协作的场景,其中很多场景是强调工作的顺序的。例如,A同学负责编写代码,B同学负责测试代码,C同学负责修改代码中的问题。
在一个程序的运行过程中也会有很多相似的场景,例如在下载软件中,A、B、C三个线程负责分别下载某一段数据,D线程负责周期性的统计这3个线程的下载情况,显示最新下载进度,E线程负责在所有下载任务完成后关闭计算机。
3.2 线程同步
3.2.1 wait、notify和notifyAll
在线程的协作中,一种常用的方式是wait/notify等待通知方式。等待通知方式就是将处于等待状态的线程由其他线程发出通知后重新获取CPU资源,继续执行之前没有执行完的任务。
Java提供了如下3个方法来实现线程之间的消息传递:
- wait():导致当前线程等待,并释放持有的锁;直到其他持有相同锁的线程调用notify()方法或notifyAll()方法来唤醒该线程
- notify():随机唤醒一个在此锁上等待的线程
- notifyAll():唤醒所有在此锁上等待的线程
上述3个方法必须在同步代码块或同步方法中调用,否则会出现IllegalMonitorStateException异常。
等待通知方式主要应用于如下场景:当一个线程获取锁后,发现自己不满足某些条件,不能执行锁住部分的代码,此时需要进入等待列表,直到满足条件时才会重新竞争线程。
3.2.2 【案例】两个线程交替打印数字示例
编写代码,用两个线程交替打印数字:
代码示意如下:
public class WaitNotifyDemo {
public static void main(String[] args) {
Number number1 = new Number();
Thread t1 = new Thread(number1);
Thread t2 = new Thread(number1);
t1.setName("线程1");
t2.setName("线程2");
t1.start();
t2.start();
}
}
class Number implements Runnable {
private int number = 1;
@Override
public void run() {
while (true) {
synchronized (this) {
// 唤醒等待池中的一个线程,该线程进入锁池,等待当前线程释放锁
this. notify();
String name = Thread.currentThread().getName();
// 当前线程执行打印操作
if (number <= 10) {
System.out.println( name + "打印" + number);
number++;
} else{
break;
}
try {
// 当前线程进入等待池,并释放持有的锁
this. wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
3.2.3 等待阻塞状态
当一个线程因wait()方法进入阻塞状态时,该线程处于等待阻塞状态。当一个处于等待阻塞的线程被notify()或notifyAll()方法唤醒时,该线程先进入同步阻塞状态,得到锁后进入可运行状态。
线程状态如下图所示: