文章目录
前言
随着计算机发展,一直存在一个核心矛盾,即**CPU、内存、IO设备的速度差异**。这三者的执行速度由快到慢依次为,CPU>内存>IO设备,为平衡这三者的速度差异,做出了如下三方面的努力:
- CPU增加缓存,均衡与内存的速度差异
- 操作系统增加进程、线程,以分时复用CPU,均衡与IO设备的速度差异
- 编译程序对指令执行次序进行优化,更加高效的利用缓存
一、并发编程三要素
1.定义
-
可见性:
线程修改共享变量的结果,对其他线程可见
-
原子性:
一个或多个操作在CPU执行过程中不会被中断,要么全执行,要么全不执行
-
有序性
程序按照代码先后顺序执行
2.可见性
2.1 可见性问题
在多核CPU中,不同的处理器执行不同的线程,每个处理器为了提高处理的效率,增加了独有的高速缓存,减少了与内存的交互。
在特殊情况下,高速缓存与内存的变量存在不一致的情况,如下图所示,当线程1执行c+1操作,修改了变量c的值为1,但还未同步至内存时,线程2获取内存中的c=0,执行c+1操作时就出现了缓存不一致问题
2.2 可见性问题根源
由上述分析可知,出现可见性问题的根源为,CPU高速缓存与内存间的缓存不一致问题
3.原子性
3.1 原子性问题
为了提高CPU的执行效率,在等待IO处理时,允许CPU进行线程切换,即重新选择一个线程来执行,如下图所示。
在编程语言中,一条语句可能会编译成多条CPU指令,即编程语言的语句与实际执行的CPU指令并非一一对应的关系,在多条指令执行的过程中,若出现了CPU调度线程切换,就会出现原子性问题。
3.2 原子性问题根源
由上述分析可知,出现原子性问题的根源为,执行多个CPU指令时发生线程切换
4.有序性
4.1 有序性问题
编译器为了提高执行效率,会对语句进行编译优化,即改变程序的执行顺序,但不改变程序最终执行的结果。 如new Object():
语句执行顺序如下:
分配内存------在内存上初始化对象-------引用地址赋值
经过编译器优化后:
分配内存------引用地址赋值----------------在内存初始化对象
4.2 重排序分类
-
编译器重排序
如果语句没有先后依赖关系,那么为了优化性能,编译器可以重新调整语句的执行顺序。 -
CPU指令重排序
在指令级别,让没有依赖关系的多条指令并行执行。 -
CPU内存重排序
CPU有自己的缓存指令的执行顺序和写入主内存的顺序没有完全一致。
4.3 有序性问题根源
由上述分析可知,出现有序性问题的根源为,编译优化带来的指令重排序
二、并发底层解决方案(Java内存模型)
1.定义
Java内存模型(JMM)从使用者(程序员)的角度来看,可以理解为解决并发问题的工具,按需对指令重排序(有序性问题)、线程切换(原子性问题)、CPU缓存(可见性问题)进行禁用。
2.volatile
2.1 语义
volatile主要用于解决变量可见性问题
- 禁止CPU缓存
写变量时立即刷新至主内存,其他线程缓存失效 - 禁止重排序
插入特定类型的内存屏障指令 - 单个volatile变量的读写具有原子性
如count++这类操作不具备原子性
2.2 作用范围
修饰变量
3.synchronized
3.1 语义
synchronized主要用于解决原子性问题,保证同一时间只能有一个线程访问,即禁用线程切换,本质是通过对临界区上锁,保护临界区内的共享资源。
- 禁止CPU缓存
加锁时从主内存取,解锁时刷新到主内存 - 禁止线程切换
线程间互斥
3.1 作用范围
-
修饰代码块
被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象 -
修饰方法
被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象 -
修饰静态的方法
其作用的范围是整个静态方法,作用的对象是这个类的所有对象
4.final
4.1 语义
final修饰的内容在初始化完成后不可变,只要保证构造方法中未出现对象逸出(将this赋值给其他变量),则final是线程安全的
-
禁用CPU缓存
final 变量会被缓存在寄存器中而不需要去内存获取 -
初始化可见性
同时为保证线程安全,1.5以后对final关键字进行了增强,保证final变量的写先于final对象的读先于final变量的读,这一约束解决了之前存在的构造方法溢出的问题(构造方法不为原子,其他线程可能读取到初始化一半的对象)
4.2 作用范围
-
修饰类
final 修饰类的时候代表这个类是不可以被扩展继承的,例如 JDK 里面的 String 类。 -
修饰方法
final 修饰方法的时候代表这个方法不能被子类重写。 -
修饰变量
final 修饰变量的时候,这个变量一旦被赋值就不能再次被赋值。
5.Happens-Before
5.1 定义
Happens-Before是一套规则,定义了Java程序运行顺序的标准,意为前一个操作对后一个操作是可见的,或者前一个操作先行发生于后一个操作发生
5.2 程序次序原则
在同一个线程内,程序执行的先后顺序按照代码顺序执行
5.3 传递性原则
如果A先于B,B先于C,那么A先于C
5.4 volatile 变量规则
volatile变量的写入先于volatile变量的读取,由于volatile变量在写入时会直接写入内存,故读取时可以保证读取到最新的数据
5.5 final变量规则
final变量的写入先于final所在对象的读取先于final对象的读
5.6 锁定解锁规则
锁的解锁先于锁的加锁,由于解锁后会强制刷新主存,下一次加锁时获取的是最新的数据
5.7 线程生命周期规则
实际都是通过刷新主存实现可见性
-
start()
A线程启动B线程的start方法,那么B能看到A线程启动前的任意操作 -
join()
A线程调用B线程的join方法,那么在B执行完成后,A能看到B的执行结果 -
interrupt()
A线程调用B线程的interrupt方法,那么在B能看到A线程中断前的任意操作
5.8 对象终结原则
对象的初始化先于对象的finalize
三、并发基础
1.线程创建
1.1 继承Thread类
1.1.1 Thread类使用
public class ThreadTest {
public static void main(String[] args) {
//继承Thread
ThreadDemo1 threadDemo1 = new ThreadDemo1();
threadDemo1.start();
}
private static class ThreadDemo1 extends Thread {
@Override
public void run() {
System.out.println("Thread线程开始运行");
}
}
}
1.1.2 Thread类API
Thread中获取对象信息:
方法名 | 说明 |
---|---|
getId() | 返回线程对象唯一标识符 |
getName()/setName() | 获取或设置线程对象名称 |
getPriority()/setPriority() | 获取或设置优先级(无法保证线程的执行顺序) |
isDaemon()/setDaemon() | 获取或设置是否为守护线程(守护线程随主线程的关闭也会关闭,通常用于执行辅助任务,如垃圾收集器) |
getState() | 获取线程对象状态 |
Thread中线程通信相关:
方法名 | 说明 |
---|---|
interrupt() | 中断目标线程,给目标线程发送一个中断信号,线程被打上中断标记 |
interrupted() | 判断目标线程是否被中断,但是将清除线程的中断标记 |
isinterrupted() | 判断目标线程是否被中断,不会清除中断标记 |
sleep(long ms) | 该方法将线程的执行暂停ms时间 |
join() | 暂停当前线程,执行加入的进程,加入进程执行完毕后,继续执行当前线程 |
yield() | 当前线程做出让步,主动放弃对CPU的占用,从运行状态转变为就绪状态,CPU从就绪状态的线程中随机选择一个线程执行(当前线程仍能被选中) |
Thread中其他方法:
方法名 | 说明 |
---|---|
setUncaughtExceptionHandler() | 设置未校验异常处理器 |
currentThread() | Thread类的静态方法,返回实际执行该代码的Thread对象 |
1.2 实现Runnable接口
public static void main(String[] args) {
//实现Runnable
Thread threadDemo2 = new Thread(new ThreadDemo2());
threadDemo2.start();
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Runnable简写线程开始运行");
}
}).start();
}
private static class ThreadDemo2 implements Runnable {
@Override
public void run() {
System.out.println("Runnable线程开始运行");
}
}
}
1.3 实现Callable接口(有返回值)
Callable与Runnable最典型的区别为:
- Runnable 无返回值,Callable有返回值
- Runnable不会抛出异常,Callable会抛出异常(可以实现自己的执行器并重载afterExecute()方法来处理异常)
- Runnable是Java原生接口,Callable是JUC中的接口
public class ThreadTest {
public static void main(String[] args) {
//创建FutureTask的对象
FutureTask<String> task = new FutureTask<>(new ThreadDemo3());
//创建thread对象
Thread thread = new Thread(task);
thread.start();
//简写
new Thread(new FutureTask<String>(new Callable<String>() {
@Override
public String call() throws Exception {
System.out.println("简写callable开始运行");
return "简写callable返回值";
}
})).start();
}
private static class ThreadDemo3 implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println("Callable线程开始运行");
return "这是Callable线程调用的返回值";
}
}
}
2.线程锁
2.1 定义
锁是用于保护某一个资源在同一时间只有一个线程能访问的工具,核心就是共享资源的访问互斥。
通用的加锁流程如下:
2.2 synchronized
synchronized是Java语言原生实现的一种锁,里面封装了对临界区的加锁解锁操作。
2.2.1 修饰静态方法
修饰静态方法时,该锁锁定的当前对象的所有实例(当前类的Class对象)
public class Test {
synchronized static void method() {
// 临界区
}
}
等价于
public class Test {
static void method() {
synchronized (Test.class) {
// 临界区
}
}
}
2.2.2 修饰非静态方法
public class Test {
synchronized void method() {
// 临界区
}
}
等价于
public class Test {
static void method() {
synchronized (this) {
// 临界区
}
}
}
2.2.3 修饰代码块
synchronized也可以修饰一段代码块,相较整个方法都上锁,只上锁关键的共享资源的访问,要更加轻量级些。
public class Test {
private final Object lock = new Object();
void method() {
//可以锁该类的所有实例
synchronized (Test.class) {
// 临界区
}
//仅仅锁住当前实例
synchronized (this) {
// 临界区
}
//也可以把其他对象当做锁
synchronized (lock) {
// 临界区
}
}
}
2.3 锁的本质
2.3.1 锁和资源的关系
- 锁与共享资源间的关系是1:N
如上图所示,当锁与资源1:1时,可以理解为,一把专用的钥匙只能开一个专用的门,当锁与资源1:N时,可以理解为一把万能钥匙可以开多个门
public class Test2 {
private volatile int kitchen;
private volatile int toilet;
private volatile int bedroom;
private final Object lock = new Object();
void door1() {
//厨房
synchronized (lock) {
// 临界区
kitchen++;
}
}
void door2() {
//客厅
synchronized (lock) {
// 临界区
toilet++;
}
}
void door3() {
//卧室
synchronized (lock) {
// 临界区
bedroom++;
}
}
}
- 锁即可以是其他对象也可以是资源本身
例如上文中的Test2中,锁即为其他对象,当通过synchronized(this) {…}这种方式申明时,资源本身也成为了锁。
2.3.2 锁实现
由于任何对象都可能成为锁,故在进行设计时,跟锁通信相关的方法如wait()、notify()等,在顶级父类Object类中定义,而非在Thread中。
锁的实现原理为,在对象的头中存在一块数据Mark Word,该数据用于存放锁的标志位(记录当前对象的锁状态,如1已占用 0-未占用等)、占用该锁的thread ID(每个线程唯一标识符)及线程阻塞队列(记录未获取到锁的线程,依次排队阻塞等待)
3.线程通信
线程中的wait()/notify()/notifyAll()必须依赖synchronized 进行加锁,加锁后才能使用,否则会抛出IllegalmoitorStateException异常,出现这个异常的实际原因是在synchronized 加锁后,会为当前加锁对象创建监视器,该监视器主要用于控制进程间同步及通信,只有在监视器内才可以完成通信的动作,这一整套体系也被称为管程,将在下面的章节详细介绍。
3.1 示例
顾客在奶茶店买奶茶,如果有奶茶就直接购买,没奶茶排队:
public class MilkTeaShop {
private volatile int milkTea = 0;
public static void main(String[] args) {
MilkTeaShop milkTeaShop = new MilkTeaShop();
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+"准备买奶茶");
milkTeaShop.customer();
System.out.println(Thread.currentThread().getName()+"买到了");
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+"准备买奶茶");
milkTeaShop.customer();
System.out.println(Thread.currentThread().getName()+"买到了");
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
milkTeaShop.producer();
System.out.println("奶茶做好了");
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
milkTeaShop.producer();
System.out.println("奶茶做好了");
}
}).start();
}
void customer() {
synchronized (this) {
//没奶茶,排队等待
while (milkTea == 0) {
System.out.println(Thread.currentThread().getName()+"开始排队");
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
milkTea--;
}
}
void producer() {
//生产奶茶,叫等待的顾客来买
synchronized (this) {
milkTea++;
notifyAll();
}
}
}
执行效果如下:
Thread-0准备买奶茶
Thread-0开始排队
Thread-1准备买奶茶
Thread-1开始排队
奶茶做好了
Thread-1买到了
Thread-0开始排队
奶茶做好了
Thread-0买到了
3.2 wait
加锁后调用wait()会使当前线程阻塞,等待其他线程使用notify()或notifyAll()唤醒,wait()在使用过程中有如下要点:
-
执行wait()时,会先释放锁
wait()执行流程如下,先释放持有的锁,阻塞等待被其他线程唤醒,唤醒后重新获得锁,完成剩下的操作后,退出synchronized 再次释放锁。这样做的原因是为了避免死锁,即当前线程获得锁后进入阻塞状态,其他线程永远无法获取到锁。 -
wait()阻塞后,会进入对象锁的等待队列,等待被唤醒
这就意味着调用wait的对象,必须是锁对象,如果 synchronized 锁定的是 this,那么对应的一定是 this.wait(),如果是锁定的是object,必须调用object.wait()否则会抛出IllegalmoitorStateException异常 -
wait()会抛出中断异常InterruptedException
关于相关的异常,将在线程中断中详细介绍
3.2 notify、notifyAll
加锁后调用notify()会唤醒一个正在等待该对象锁的线程,notifyAll()会唤醒所有正在等待该对象锁的线程,在使用过程中有如下要点:
-
执行notify()/notifyAll()时,唤醒的是处于对象锁等待队列中的线程
即notify()/notifyAll()与wait的使用方式一样,需要调用跟锁的对象保持一致 -
notify()是随机唤醒一个线程,可能导致某些线程永远不会被唤醒
例如有两份资源(A\B),线程1获取A,线程2获取B,线程3想获取A但被线程1占用,进入阻塞,线程4想要获取B被线程2占用进入阻塞,此时线程1执行完毕,调用notify随机唤醒了线程4,线程4依然无法获取资源B,继续阻塞,此时线程3就无法被唤醒了。
3.3 join
A.join(),当前线程阻塞,待A线程执行完毕后唤醒当前线程继续执行
public static void main(String[] args) {
System.out.println("主线程执行");
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("开始执行");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行完毕");
}
});
thread.start();
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("主线程执行完毕");
}
}
执行结果为:
主线程执行
开始执行
执行完毕
主线程执行完毕
4.线程中断
4.1 interrupt
在java中可以实现线程中断的方法为interrupt方法,意为中断轻量级阻塞(无法中断synchronized等重量级阻塞),只有如下方法可以响应该中断,并抛出InterruptedException异常
- public static native void sleep(long millis) throws InterruptedException {…}
- public final void wait() throws InterruptedException {…}
- public final void join() throws InterruptedException {…}
示例如下
public static void main(String[] args) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
System.out.println("sleep被中断");
}
}
});
thread.start();
thread.interrupt();
}
4.2 thread.isInterrupted()与Thread.interrupted()
thread.isInterrupted()与Thread.interrupted()这两者都用于检测线程是否被中断,并返回一个boolean值,两者之间的区别如下
- isInterrupted()是实例方法,interrupted是静态方法
- isInterrupted()是调用对象的线程是否被中断过,interrupted()是当前线程是否被中断过
- isInterrupted()不会清除线程的中断标记,interrupted会清除
public static void main(String[] args) {
System.out.println("================Thread.interrupted()是否会清除中断标识");
Thread thread1 = new InterruptedDemo1();
thread1.start();
thread1.interrupt();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("================isInterrupted是否会清除中断标识");
Thread thread2 = new InterruptedDemo2();
thread2.start();
thread2.interrupt();
System.out.println("线程2是否被中断过:"+thread2.isInterrupted());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("================抛出异常是否会清除中断标识");
Thread thread3 = new InterruptedDemo3();
thread3.start();
thread3.interrupt();
}
private static class InterruptedDemo1 extends Thread{
@Override
public void run() {
System.out.println("interrupted前thread是否被中断过:"+isInterrupted());
System.out.println("interrupted调用结果,thread是否被中断过:"+Thread.interrupted());
System.out.println("interrupted后是否被中断过:"+isInterrupted());
}
}
private static class InterruptedDemo2 extends Thread{
@Override
public void run() {
System.out.println("线程2是否被中断过:"+isInterrupted());
}
}
private static class InterruptedDemo3 extends Thread{
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
System.out.println();
System.out.println("线程3是否被中断过:"+isInterrupted());
}
}
}
运行结果如下,由结果可知interrupted和抛出异常InterruptedException会清除中断标识,isInterrupted不会清除中断标识
================Thread.interrupted()是否会清除中断标识
interrupted前thread是否被中断过:true
interrupted调用结果,thread是否被中断过:true
interrupted后是否被中断过:false
================isInterrupted是否会清除中断标识
线程2是否被中断过:true
线程2是否被中断过:true
================抛出异常是否会清除中断标识
线程3是否被中断过:false
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at com.caizy.thread.Interrupt$InterruptedDemo3.run(Interrupt.java:60)
Process finished with exit code 0
5.线程终止
5.1 stop()、destory()
线程可以通过stop()、destory()进行关闭,但官网不建议用这两种方式杀死线程,因为这两种方式只停止了线程,并未释放线程中的资源如网络资源、文件资源等。应等到线程执行完毕,释放资源后再退出
5.2 优雅关闭线程
可以通过设置关闭标志位的方式中断线程,伪代码如下
//定义关闭的标识
boolean running = true;
while (running) {
//执行线程逻辑,注意避免while循环内部阻塞,无法关闭的情况
}
//关闭线程
running = false;
6.线程生命周期
6.1 线程状态
线程的状态一共有五种,称为五态模型
- 创建状态(NEW)
当用 new 操作符创建一个线程的时候 - 就绪状态(READY)
调用 start 方法,处于就绪状态的线程并不一定马上就会执行 run 方法,还需要等待CPU的调度 - 运行状态(RUNNING)
CPU 开始调度线程,并开始执行 run 方法 - 阻塞状态(BLOCKED)
线程的执行过程中由于一些原因进入阻塞状态比如:调用 sleep 方法、尝试去得到一个锁等等 - 终止状态(TERMINATED)
run 方法执行完或者执行过程中遇到了一个异常
6.2 JAVA线程状态
JAVA对阻塞状态进行了一些修改,对应修改如下:
- 合并运行状态(RUNNING)和就绪状态(READY)为RUNNABLE(可运行 / 运行状态)
JVM 层面并不关心操作系统调度相关的状态,把这部分内容交给了操作系统 - 扩充阻塞状态(BLOCKED),新增WAITING(无时限等待)、TIMED_WAITING(有时限等待)
之所以扩充这两个状态,是为了对阻塞状态做细分,我们把阻塞状态(BLOCKED)称为重量级阻塞,不能被中断,WAITING(无时限等待)、TIMED_WAITING(有时限等待)为轻量级阻塞,可以被中断
6.3 线程状态转化
线程的状态变化如上图所示,可以看出WAITING(无时限等待)、TIMED_WAITING(有时限等待)的区别为是否设置超时时间
四、并发通信(线程同步)
1.定义
-
同步
指线程之间通信、协作的机制,协调一个或多个线程按使用者预期的目标执行。 -
互斥
指同一资源在同一时间内只允许有一个线程访问,具有排他性
由上述定义可知,同步的概念中已经包含了互斥相关的概念,可以理解为互斥是一种特殊的同步。
2.同步的方式
-
控制同步
控制任务的执行顺序 -
数据访问同步
控制共享变量同一时间只有一个线程访问
3.同步的机制
目前主要有如下两种实现同步的机制,由于Java使用管程来实现对象的同步,故下文中将详细介绍管程的概念
-
信号量(semaphore)
定义一个变量记录线程状态或数量(信号),其他线程根据该变量判断自己应该执行的操作。 -
监视器/管程(Monitor)
通过对共享资源创建监视器,以达到管理共享资源及控制共享资源访问的效果的一种机制。
4 管程
4.1 定义
管程的实质是对象监视器,封装了共享变量及对共享变量的操作,任何线程需要访问共享资源,需要排队进入监视器入口,监视器判断条件是否成立,若不成立继续回入口排队,成立则访问共享资源。
Java实现中,给对象加锁的过程中会给该对象创建监视器,当其他线程访问该对象保护的共享资源时,会被监视器监管。
4.2 管程模型(MESA模型)
有关管程的模型,先后共出现了三种模型Hasen模型,Hoare模型,由于JAVA中实现的管程参考了MESA模型,故下面重点介绍MESA模型
- 临界区
保护共享资源,实现线程间互斥的代码块称为临界区,图中黑框部分为临界区,同时间内只能有一个线程进入临界区。 - 入口
通过对临界区加锁的方式,实现互斥访问 - 入口等待队列
未获取到锁的线程,阻塞后进入队列。 - 条件变量
按不同条件将线程归类 - 条件变量等待队列
属于同一类条件的线程,不满足条件变量定义的条件时,阻塞后进入该队列,满足条件后,重新进入入口等待队列
4.2 MESA模型实现
4.2.1生产者消费者模型
生产者消费者模型,即有多个生产者生产物品存入队列,多个消费者消费物品,取出队列,队列实现的核心要点如下:
- 一把锁
生产的方法,与消费的方法需要加锁,由于操作的是同一个队列,故使用同一把锁 - 两个条件
队列满时阻塞生产者,队列不满时唤醒生产者;队列空时阻塞消费者,队列不空时唤醒消费者;
4.2.2 代码实现
synchronized实现方法如下:
public class SynchronizedQueue<E> {
private final Object[] items;
private int takeIndex;
private int putIndex;
private int count;
public SynchronizedQueue(int capacity) {
items = new Object[capacity];
}
public synchronized void enqueue(E x) {
while (count == items.length){
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
items[putIndex] = x;
if (++putIndex == items.length){
putIndex = 0;
}
count++;
notifyAll();
}
public synchronized E dequeue() {
while (count == 0){
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
E item = (E) items[takeIndex];
items[takeIndex] = null;
if (++takeIndex == items.length){
takeIndex = 0;
}
count--;
notifyAll();
return item;
}
}
public class Main {
public static void main(String[] args) {
SynchronizedQueue<String> queue = new SynchronizedQueue<>(10);
CustomerThread customerThread;
for (int i = 0; i < 10; i++) {
customerThread = new CustomerThread(queue);
customerThread.start();
}
ProducerThread producerThread;
for (int i = 0; i < 10; i++) {
producerThread = new ProducerThread(queue);
producerThread.start();
}
}
private static class ProducerThread extends Thread {
private final SynchronizedQueue<String> queue;
private final Random random = new Random();
public ProducerThread(SynchronizedQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
String name = "物品" + random.nextInt(100);
System.out.println("线程"+Thread.currentThread().getName()+"开始生产物品"+name);
queue.enqueue(name);
}
}
private static class CustomerThread extends Thread {
private final SynchronizedQueue<String> queue;
public CustomerThread(SynchronizedQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
String name = queue.dequeue();
System.out.println("线程"+Thread.currentThread().getName()+"开始消费物品"+name);
}
}
}
该方法有一个明显弊端,会同时唤醒所有阻塞在队列中的消费者和生产者,为了对生产者阻塞队列及消费者阻塞队列做区分引入了Lock及Condition,代码如下:
public class LockQueue<E> {
private final Lock lock;
/**
* 消费者阻塞队列
*/
private final Condition notEmpty;
/**
* 生产者阻塞队列
*/
private final Condition notFull;
private final Object[] items;
private int takeIndex;
private int putIndex;
private int count;
public LockQueue(int capacity) {
items = new Object[capacity];
lock = new ReentrantLock();
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
public void enqueue(E x) {
lock.lock();
try {
while (count == items.length) {
try {
//阻塞生产者
notFull.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
items[putIndex] = x;
if (++putIndex == items.length) {
putIndex = 0;
}
count++;
//不空唤醒消费者
notEmpty.signal();
} finally {
lock.unlock();
}
}
public E dequeue() {
lock.lock();
try {
while (count == 0) {
try {
//阻塞消费者
notEmpty.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
E item = (E) items[takeIndex];
items[takeIndex] = null;
if (++takeIndex == items.length) {
takeIndex = 0;
}
count--;
//不满唤醒生产者
notFull.signal();
return item;
} finally {
lock.unlock();
}
}
}
public class Main2 {
public static void main(String[] args) {
LockQueue<String> queue = new LockQueue<>(10);
CustomerThread customerThread;
for (int i = 0; i < 10; i++) {
customerThread = new CustomerThread(queue);
customerThread.start();
}
ProducerThread producerThread;
for (int i = 0; i < 10; i++) {
producerThread = new ProducerThread(queue);
producerThread.start();
}
}
private static class ProducerThread extends Thread {
private final LockQueue<String> queue;
private final Random random = new Random();
public ProducerThread(LockQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
String name = "物品" + random.nextInt(100);
System.out.println("线程"+Thread.currentThread().getName()+"开始生产物品"+name);
queue.enqueue(name);
}
}
private static class CustomerThread extends Thread {
private final LockQueue<String> queue;
public CustomerThread(LockQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
String name = queue.dequeue();
System.out.println("线程"+Thread.currentThread().getName()+"开始消费物品"+name);
}
}
}
五、并发问题
1.安全性问题
1.1 数据竞争
存在多个线程同时对共享变量进行写入和读写,这种情况称为数据竞争。例如下面这个例子,库存为共享变量,多个线程同时对库存进行扣减
public class Stock{
private int stock = 10;
public void sell() {
stock -= 1;
}
public static class StockThread extends Thread{
private final Stock stock;
public StockThread(Stock stock) {
this.stock = stock;
}
@Override
public void run() {
stock.sell();
}
}
public static void main(String[] args) {
Stock stock = new Stock();
for (int i = 0; i < 100000; i++) {
new StockThread(stock).start();
}
//等待计算结果
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("最终剩余库存为"+stock.stock);
}
}
运行结果如下,可以看到该结果是不正确的,同时可以看出stock -= 1;语句需要先获取stock的值+1,再设置stock的值,即存在先后顺序,后一个语句依赖前一个语句,也影响程序的执行结果,这种情况我们称为竞态条件(程序的执行结果依赖线程执行的顺序)。
最终剩余库存为-99986
要想解决该问题,最简单的解决方式是上锁,在sell()方法前加上synchronized关键字,运行结果如下:
最终剩余库存为-99990
2.活跃性问题
2.1 死锁
2.1.1 定义
死锁是一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象
如图,线程1持有资源1的锁,线程2持有资源2的锁,线程1想获取资源B,则需要获取资源B的锁,但此时被线程1占用,变为阻塞状态,线程2想获取资源1,发现资源B的锁竞争不到,发生阻塞,此时两个线程都处于阻塞状态,无法被唤醒。
2.1.2 死锁的必要条件
Coffman总结了死锁产生的必要条件,有如下四条,只有四条都满足,死锁才会发生
- 互斥
共享资源 X 和 Y 只能被一个线程占用 - 占有并等待条件
线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X - 不可抢占
其他线程不能强行抢占线程 T1 占有的资源 - 循环等待
线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待
2.1.3 避免死锁
避免死锁只需要破坏上述四条死锁产生的条件其中一条即可
2.1.3.1 死锁样例
以转账为例,涉及两个资源,转出方和转入方,要想转账成功必须同时占有两个资源,就可能发生死锁
public class Account {
/**
* 余额
*/
private int balance;
/**
* 转账
*
* @param target 对方账户
* @param money 金额
*/
public void transfer(Account target, int money) {
//获取转入方的锁
synchronized (this) {
//获取转出方的锁
synchronized (target) {
if (balance >= money) {
balance = balance - money;
target.balance = target.balance + money;
}
}
}
}
}
2.1.3.2 破坏占有且等待
一次性申请所有资源,就可以避免占有且等待的情况了,可以引入第三方资源分配器
public class Allocator {
private static volatile Allocator instance = null;
/**
* 申请中列表
*/
private final List<Account> applyingList = new ArrayList<>();
private Allocator() {
}
/**
* 单例
* @return
*/
public static synchronized Allocator getInstance() {
if (instance == null) {
synchronized (Allocator.class) {
if(instance == null){
instance = new Allocator();
}
}
}
return instance;
}
public synchronized void apply(Account from, Account to) {
while (applyingList.contains(from) || applyingList.contains(to)) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
applyingList.add(from);
applyingList.add(to);
}
public synchronized void free(Account from, Account to) {
applyingList.remove(from);
applyingList.remove(to);
notifyAll();
}
}
public class Account {
/**
* 余额
*/
private int balance;
/**
* 转账
*
* @param target 对方账户
* @param money 金额
*/
public void transfer(Account target, int money) {
//申请资源
Allocator.getInstance().apply(this,target);
//获取转入方的锁
try {
synchronized (this) {
//获取转出方的锁
synchronized (target) {
if (balance >= money) {
balance = balance - money;
target.balance = target.balance + money;
}
}
}
}catch (Exception e){
e.printStackTrace();
}finally {
Allocator.getInstance().free(this,target);
}
}
}
2.1.3.3 破坏不可抢占
破坏不可抢占条件只需要让线程主动释放资源即可,synchronized 无法实现主动释放资源,加锁后会进入阻塞状态,无法执行后续的操作。而lock.tryLock()可以实现,如果获取到锁就返回true,未获取到锁立即返回false,不进行阻塞。
2.1.3.4 破坏循环等待
破坏循环等待只需要控制获取锁的顺序,为资源分配唯一标识符,按资源标识符的顺序大小来获取
public class Account2 {
/**
* 账户的唯一标识符
*/
private int id;
/**
* 余额
*/
private int balance;
/**
* 转账
*
* @param target 对方账户
* @param money 金额
*/
public void transfer(Account2 target, int money) {
//比较账户ID的大小
Account2 max = this;
Account2 min = target;
if (min.id > max.id) {
max = target;
min = this;
}
//获取转入方的锁,id小的先获取id大的后获取
synchronized (min) {
//获取转出方的锁
synchronized (max) {
if (balance >= money) {
balance = balance - money;
target.balance = target.balance + money;
}
}
}
}
}
2.1 活锁
活锁是指多个线程因对方的行为改变自己的状态,而导致陷入状态变更的无限循环,无法继续执行。 例如,路上有两个行人,行人A往左走,行人B也往左走,此时行人A看到行人B想避开,故往右走,行人B同样看到行人A想避开也往右走,此时就会出现无限循环的情况。解决该种情况最直观的就是随机采取行动,避免因对方的行动而行动。
2.3 饥饿
饥饿是指线程因无法获取到所需资源一直无法执行后续步骤的情况 ,例如设置线程的优先级,优先级低的线程有可能永远没机会执行。要解决饥饿问题可以采取三种解决方案:
- 保证资源充足
- 保证分配公平
- 避免线程长时间占有锁
1,3种的使用场景有限,通常都是采用方案2的形式解决饥饿问题,如公平锁
3.性能问题
想要提高并发程序的效率,可以从两方面入手提高程序并行度(软件效率)和提高CPU及IO设备利用率(硬件效率)
3.1 最佳线程数量
在编程层面想提高硬件利用率,可以从确定合适的线程数量入手,由于实际IO及CPU利用率无法准确估算,通常要根据压力测试,来调试最佳线程数量,故下文中只是提供一种确定最佳线程的思路
3.1.1 CPU密集型(CPU计算较多)
我们可以假设,在极端情况下,没有任何IO利用率,完全只执行CPU计算,那么最合理的线程数应等于CPU核数,若多于CPU核数会发生线程切换增加不必要的成本,通常会设置为
- CPU 核数 +1
+1是用来保证当线程因为偶尔的内存页失效或其他原因导致阻塞时,有多余的线程可以执行
3.1.2 IO密集型(IO执行较多)
单核情况下,IO时间越长,CPU空闲时间越长,需要越多的线程来提升CPU的利用率,故IO耗时与线程数量成正比,CPU耗时与线程数量成反比,多核情况只需按CPU核数等比扩大即可,可推导最佳线程数为:
- CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]
3.2 阿姆达尔(Amdahl)定律
该定律用于描述并行率与性能提升的能力,即并行度越高,性能越高
- S=1/((1−p)+p/n) 其中S代表性能,p代表并行率,n代表CPU核数
可以通过如下两种方式,提高并发的并行度
- 无锁
例如CAS(乐观锁)、TLS(线程本地存储)
- 减少锁的持有时间
通过减小锁的粒度,可以实现更高的并行度,例如分段锁、读写锁