Skip to content

Java 并发编程基础

· 18 min

进程和线程#

操作系统层面:

Java层面:

线程的优先级#

优先级就是决定线程获取时间片的优先级

​ 操作系统是采用时间分片的方式来分配处理器资源(CPU时间片),一个线程用完一个时间片后就会发生线程调度,即使线程没有执行完毕也要等下次分配时间片才能执行,所以线程获得的时间片越多,也就处理的效率更高。

​ Java线程可以使用setPrority()方法设置优先级,默认是5,可设置范围是1~10。

线程状态流转#

并发基础图片

  1. NEW :初始状态,线程刚被创建还未运行,运行需要执行start方法
  2. RUNNABLE:运行状态:线程被执行
  3. BLOCKED:线程处于被阻塞的状态,表示线程在等待synchronized 隐式锁。在获取到锁之前都会处于这个状态
  4. WAITING:线程无限期的等待,直到被唤醒。与BLOCKED的区别是:阻塞状态是被动等待,只有别人释放锁之后你才能尝试获取。而WAITING是主动等待,随时可以通过调用方法进入
  5. TIME_WAITING:超时等待,线程可在指定时间后自行返回
  6. TERMINATER:终止状态,表示线程执行完毕

线程的创建方法#

继承Thread类创建线程#

直接继承Thread 然后重写它里面的run方法

public class CreateThread1 {
public static void main(String[] args) {
M1 m1 = new M1();
m1.start();
}
}
class M1 extends Thread{
@Override
public void run() {
System.out.println("这是第一种创建线程的方法");
}
}

实现Runnable接口创建线程#

public class CreateThread2 {
public static void main(String[] args) {
M2 m2 = new M2();
new Thread(m2).start();
}
}
class M2 implements Runnable{
@Override
public void run() {
System.out.println("这是第二种创建线程的方法");
}
}

实现Callable方法和FutureTask创建线程#

这种实现方式主要是可以接收线程的返回值

public class CreateThread3 {
public static void main(String[] args) throws Exception{
M3 m3 = new M3();
FutureTask futureTask = new FutureTask<>(m3);
Thread thread = new Thread(futureTask);
thread.start();
System.out.println(futureTask.get());
}
}
class M3 implements Callable<Integer>{
@Override
public Integer call() throws Exception {
return 2+3;
}
}

由于Java只允许单继承,我们使用方法2和方法3是更好的选择

Java线程中断#

优雅中断#

Java线程中断主打一个“民主”,当前线程能知道有没有别的线程想搞事情(中断当前线程),并且还能做出应对决策(异常捕获或者轮询检查标识,然后处理)

​ 我们可以通过调用目标线程的interrupt()方法,来请求中断目标线程。目标线程需要自己检查,或在异常中处理来决定是否真正的停下来。不同状态下调用中断方法时执行的情况不同,主要是以下两种情况:

  1. runnable:线程处于这个状态的时候,线程中的isInterrupted()方法是有中断标识,并且不会抛出异常
  2. waiting 和 time waiting:都会抛出异常,但是中断标识被清理掉了,所以在这一步的时候我们一般都会重置中断标识Thread.currentThread().interrupt(); 这样,后续代码也能通过 isInterrupted() 正确感知到中断请求
class M2 extends Thread{
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
System.out.println(Thread.currentThread().getName() + " 正在运行...");
try {
// sleep 后会处于timewait 状态,这个状态的时候线程被中断会抛出异常
Thread.sleep(500);
} catch (InterruptedException e) {
// 重置中断标识
Thread.currentThread().interrupt();
break; // 退出循环
}
}
System.out.println(Thread.currentThread().getName() + " 已退出");
}
}

暴力中断#

​ Java提供了几个方法可以直接中断线程,不需要经过线程同意。stop()、suspend()不过这些方法现在被标记为废弃了。因为如果强行停止线程可能会导致数据不完整等其他问题。例如:线程A正在写入一个内容到文件中,结果瞬间被叫停了,这样还是很不好的。

线程通信#

每个线程并不是独立进行的,只有互相协作才能完成业务的要求。想要互相协作就要有通信的方式

在 Java 中,线程通信通常结合以下技术实现:

掌握线程通信是编写正确、高效并发程序的关键基础。

上下文切换#

线程切换我们可以理解为系统要保存当前线程的状态,然后恢复另一个线程的状态,继续执行另一个线程

上下文切换会导致CUP额外的开销,性能下降等问题

我们可以通过控制线程数、减少阻塞、使用线程池、避免锁竞争等方式来减少上下文切换的次数

为什么需要多线程#

​ 现在的计算机都有多个核心,而一个线程同一时间只能用一个核心,所以会造成资源浪费。并且多线程开发可以充分利用系统多核的优势,让程序效率更高

内存屏障#

​ 内存屏障(Memory Barrier)或栅栏(Fence),是为了解决CPU乱序执行和高速缓存与主存之间数据一致性问题的一种机制。当线程遇到一个内存屏障时,它会强制刷新自己的工作内存中的数据到主内存,并且重新从主内存中读取其他线程可能更新过的共享变量值到自己的工作内存中。这就确保了线程间的数据可见性。

引发并发问题的原因#

可见性:内存模型#

看下面的代码,你觉得线程会停下来吗?

static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ...
}
});
t.start();
sleep(1);
run = false;
}
  1. 初始状态, t 线程从主内存读取 run 的值到工作内存。

  2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中, 减少对主存中 run 的访问,提高效率

  3. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量 的值,结果永远是旧值

解决办法:使用 volatile(易变关键字) 它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取 它的值,线程操作 volatile 变量都是直接操作主存

注意:synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是 synchronized 是属于重量级操作,性能相对更低。但如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到 对 run 变量的修改了。因为println()方法里有加锁操作

public void println(String x) {
synchronized (this) {
print(x);
newLine();
}
}

有序性:重排序#

JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码

// 假设 arr 是大数组,不在缓存中
int x = arr[1000000]; // (1) 内存读取,可能耗时 100ns+
int y = 5; // (2) 立即可执行
int z = x + 10; // (3) 必须等 x

看下面的代码:

Thread t1 = new Thread(() -> {
a = 1; // (1)
x = b; // (2)
});
Thread t2 = new Thread(() -> {
b = 1; // (3)
y = a; // (4)
});
//输出(x,y)

按照正常的思维理解,是不是不管谁先执行完毕,(x,y)都不会出现(0,0)的可能性。看似合理,其实在重排序后可能会出现:

//线程 t1
x = b; // (2) 先执行
a = 1; // (1) 后执行
//线程 t2
y = a; // (4) 先执行
b = 1; // (3) 后执行
时序上:
x = b;
y = a;
a = 1;
b = 1;

这就导致(0,0)的出现了。

我们可以利用 volatile ,锁等加以控制

原子性:分时复用#

原子性指的是一个操作要么执行完要么就不执行,我们用 i+=1这个操作举例子:这个操作虽然只有一行代码,但是需要三条CPU指令:

1. 把i的值读取到本地内存
2. i+1
3. 更新内存中的i值

由于上下文切换的存在,线程1执行了操作1,切换到线程2去了,线程2执行完毕之后,又切回了线程1。最后写到内存里的结果是2,线程1直接覆盖了内存的结果。

happen-before#

内存屏障是实现happen-before的手段之一

happen-before是Java内存模型最核心的概念之一,他约束了程序逻辑上的顺序关系,防止随意重排,用来保证多线程程序中操作的可见性和有序性

如果 A happen-before B,那么

规则#

  1. 单线程顺序规则:单线程内,程序前面的操作要 happen-before 后面的操作
  2. monitor锁规则:一个锁的解锁操作要 happen-before 后面对同一个锁的加锁操作,可以理解为 A 对Obj对象解锁 happen-befor B 对Obj对象加锁
  3. volatile 规则:对于volatile修饰的变量,写操作要 happen-before 读操作
  4. 传递性:如果 A happen-before B 并且 B happen-before C,那么 A happen-before C
  5. start规则:如果线程A 中执行startB ,那么在线程A中执行的所有写的操作,都 happen-before 线程B
  6. join规则:线程 A 执行 线程B的 join 操作,那么线程B中的操作都 happen-before 执行join后

synchronized#

synchronized 是 Java 中最基础、最重要的线程同步机制之一,它的核心作用是 保证多线程环境下对共享资源的安全访问

作用范围#

  1. 修饰代码块:被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码
  2. 修饰方法:被修饰的方法称为同步方法,其作用的范围是整个方法
  3. 修饰静态方法:作用的对象是这个类;

原理#

synchronized 基于对象头 Mark Word 和 Monitor 机制,通过锁升级(偏向→轻量→重量)在保证线程安全的同时尽可能减少性能开销,并利用内存屏障确保多线程间的可见性与有序性。

核心机制#

在 JVM 中,每个 Java 对象都关联一个 Monitor(监视器),加锁和解锁本质上就是获取监视器的过程,monitor中包含:

更详细:

class ObjectMonitor {
// 1. 持有该 Monitor 的线程
void* volatile _owner; // 指向当前持有锁的线程(Thread*)
// 2. 等待获取锁的线程队列(Entry Set)
ObjectWaiter* _EntryList; // 阻塞等待进入临界区的线程链表
// 3. 调用 wait() 后进入的等待队列(Wait Set)
ObjectWaiter* _WaitSet; // 调用 wait() 的线程链表
// 4. 等待队列的互斥保护(用于操作 WaitSet/EntryList 的锁)
volatile int _waiters; // 等待中的线程数
volatile int _recursions; // 重入次数(可重入锁的关键!)
// 5. 关联的 Java 对象(即 synchronized(obj) 中的 obj)
jobject _object; // 指向被 monitor 的 Java 对象
// 6. 其他辅助字段(如 cxq - Concurrent Queue,用于快速入队)
ObjectWaiter* _cxq; // 竞争队列(新来的线程先放这里)
};

字节码层面#

synchronized (obj) {
// 临界区
}

编译后,synchronized 会被转换为 JVM 字节码指令:

monitorenter // 尝试获取 obj 的 monitor 锁
...
monitorexit // 释放 obj 的 monitor 锁
monitorexit // 异常时也保证释放,避免死锁(编译器自动加)

可重入的实现原理#

我们在上面说了monitor的详细数据结构,里面有一个字段记录了重入的次数。当一个线程反复对一个对象加锁的时候,这个重入次数会+1,每触发一次加锁就会+1,释放的时候-1。当这个为0的时候才真正表示释放了锁。

如何解决并发的问题#

  1. 通过互斥锁限制只有一个线程能进入 synchronized 保护的代码块或方法 确保了原子性
  2. synchronized的核心是monitor锁规则,根据happen-before规则,monitor锁的规则是:A对Obj解锁的操作 happen-before B加锁也就是说:通过内存屏障+happen-before 来确保可见性和有序性

volatile#

volatile的原理是基于 happen-before 的规则和内存屏障来的,happen-before 有个volatile规则:被volatile修饰的变量写操作 happen-before 读操作。确保了有序性和可见性,但是没办法保证原子性。但是有一个特殊情况:

volatile特殊情况下可以保证原子性#

用volatile修饰long类型和double可以保证操作的原子性

出于 Java 编程语言内存模型的目的,对非易失性 long 或 double 值的单次写入被视为两次单独的写入<每个32> 位写入一次一半。这可能会导致线程从一次写入中看到 64位值的前 32 位,而从另一次写入中看到第二个 32 位。易失性长整型和双精度值的写入和读取始终是原子的。引用的写入和读取始终是原子的,无论它们是实现为 32 位值还是 64 位值.