TechBlog
首页分类标签搜索关于

© 2025 TechBlog. All rights reserved.

多线程三线程安全

11/22/2025
未分类#开发语言#安全#Java

多线程(三):线程安全

1. 线程安全的概念

如果多线程环境下代码运行的结果是符合预期的,即在单线程环境下应该的结果,则说这个程序是线程安全的。反之,如果在单线程环境下运行正确,而在多线程环境下运行出现 bug,则为线程不安全或存在线程安全问题。

2. 一个常见线程不安全实例

// 此处定义一个 int 类型变量
private static int count;

public static void main(String[] args) throws InterruptedException {
	// 线程 t1
	Thread t1 = new Thread(() -> {
		for(int i = 0; i < 50000; i++) { // 对 count 变量自增 5w 次
			count++;
		}
	});

	// 线程 t2
	Thread t2 = new Tread(() -> {
		for(int i = 0; i < 50000; i++) { // 对 count 变量自增 5w 次
			count++;
		}
	});
	
	// 开启线程
	t1.start();
	t2.start();

	// 主线程等待 t1、t2 线程结束
	t1.join();
	t2.join();

	System.out.println("count = " + count); //预期结果是 10w
}

运行这段代码,count 结果小于 10w 且每次运行结果是不确定的,出现了线程安全问题。

3. 线程不安全的原因

3.1 根本原因:随机调度

操作系统上的线程是 “抢占式执行” (“随机调度”),使线程之间的执行顺序出现很多变数;

3.2 代码结构

线程不安全问题也与代码结构有关。代码中多个线程同时修改同一个变量会导致线程不安全。

3.3 原子性

多线程修改操作不是 “原子的”。一个线程执行这些指令,执行到一半就可能被调度走,其他线程执行指令。

例如上面实例, count++ 这个操作本质上是由三个 cpu 指令构成的:

  1. load:把内存数据加载到寄存器中
  2. add:进行 +1 运算
  3. save:把寄存器数据写回到内存中

在这里插入图片描述

3.4 内存可见性

可见性指一个线程对共享变量值的修改,能够及时被其他线程看到。

CPU 中访问自身寄存器以及高速缓存的速度,要远远高于访问内存的速度。在一些情况下,编译器发现,每次都要读取 “主内存”(内存),开销太大,于是就把数据从 “主内存” 复制到 “工作内存”(寄存器、缓存) 中,后序每次都读取 “工作内存”。

此时,如果另一个线程修改了共享变量的值,但这个线程一直读取 “工作内存”,无法发现修改,就造成了线程不安全。

其他原因,如指令重排序也会导致线程安全问题,这里不做介绍。

4. 解决线程不安全问题

4.1 synchronized 关键字

可以通过加锁操作,将多个 cpu 指令打包到一起,使它们成为一个“整体”;Java 中,最常用的加锁方式是使用 synchronized 关键字。

4.1.1 互斥

首先要明确一点,进行加锁时要先准备好“锁对象”,加锁 / 解锁操作,都是依托于这个“锁对象”来展开的。

synchronized 会起到互斥效果,如果一个线程已经针对一个对象加上锁,其他线程尝试对这个对象加锁时,就会产生阻塞(处于 BLOCKED 状态),一直阻塞到前一个线程释放锁为止。这里,因为加锁产生的阻塞叫做锁冲突 / 锁竞争。

private static int count;

public static void main(String[] args) throw InterruptedException {
	// 定义一个任意类型的对象
	Object locker = new Object();
	
	Thread t1 = new Thread(() -> {
		for(int i = 0; i < 50000; i++) {
			synchronized(locker) { //使用 synchronized 关键字加锁
				count++;
			}
	});

	Thread t2 = new Thread(() -> {
		for(int i = 0; i < 50000; i++) {
			synchronized(locker) { //使用 synchronized 关键字加锁
				count++;
			}
		}
	});

	t1.start();
	t2.start();
	t1.join();
	t2.join();

	System.out,println("count = " + count);
}

通过使用 synchronized 关键字加锁,可以保证每个线程每次执行 count++ 的三个 cpu 指令具有不可拆分的效果,要全部执行玩才能执行下一组 cpu 指令,如下图:
在这里插入图片描述

【注意】

  1. synchronized() 括号中的对象可以是任意对象,关键点不在于是什么对象,而在于每个括号中是否是同一对象。只有同一个对象才能产生阻塞。 所以,使用 synchronized 并不是使一组 cpu 指令真的不可拆分,而是通过使用同一个对象达到阻塞效果,阻止另一线程的指令执行。
  2. synchronized 关键字同时有加锁和解锁的功能。在代码 synchronized(锁对象){代码块} 中,进入代码块即加锁,离开代码块即解锁。保证了只要代码块结束,无论是抛异常、return 还是别的情况,解锁都能执行到。
  3. 我们要重点理解,synchronized 锁的是什么。两个线程竞争同一把锁,才会产生阻塞等待;两个线程分别尝试获取两把不同的锁,不会产生竞争。

4.1.2 synchronized 使用示例

  1. 明确指定锁那个对象
Object locker = new Object();
public void method() {
	synchronized(locker) {
		...
	}
}

  1. 锁当前对象
public void method() {
	synchronized(this) {
		...
	}
}

  1. 直接修饰普通方法
public synchronized void method() {
	...
}

[注] synchronized 修饰普通方法相当于给 this 加锁

  1. 修饰静态对象
public synchronized static void method() {
	...
}

[注] synchronized 修饰静态方法相当于给类对象加锁

4.1.3 可重入

synchronized 对于同一线程来说是可重入的,不会出现自己把自己锁死的问题。

Object locker = new Object();
Thread t = new Thread(() -> {
	synchronized(locker) {
		synchronized(locker) {
			System.out.println("hello");
		}
	}
});

例如上面代码,因为 synchronized 是可重入的,所以代码可以正确执行。

在可重入锁的内部,包含了 “线程持有者” 和 “计数器” 两个信息:

  1. 线程持有者可以判断锁被哪个线程占用,如果恰好是自己占用,那么仍然可以继续获取到锁,并让计数器自增 1(初始值为 0);如果不是自己占用,则产生阻塞。
  2. 解锁的时候,每遇到一个 “}”,计数器自减 1,当计数器为 0 时,锁被释放。也就是说,执行到 synchronized 代码块最外层的括号之后,锁才被真正释放。

4.1.4 死锁(重要!)

Object locker = new Object();
Thread t = new Thread(() -> {
	synchronized(locker) {
		synchronized(locker) {
			System.out.println("hello");
		}
	}
});

上述代码中如果 synchronized 是一个不可重入锁,在第二次加锁时,就会产生阻塞,直到第一个锁被释放。但是,第二个锁处于阻塞状态中,代码执行不到第一个锁释放,此时线程“卡住了”,这时就称为 “死锁”。Java 中的 synchronized 是可重入锁,因此没有上面的问题。

4.1.4.1 死锁的三种典型场景
  1. 一个线程一把锁,如果锁是不可重入锁,并且一个线程对这把锁加锁两次(Java 中没有不可重复锁)
  2. 两个线程两把锁,线程1 获取到 锁A,线程2 获取到 锁B,然后 线程1 尝试获取 锁B,线程2 尝试获取 锁A
  3. N 个线程 M 把锁:哲学家就餐问题,如下图
    在这里插入图片描述
4.1.4.2 产生死锁的四个必要条件(缺一不可):
  1. 互斥使用:获取锁的过程是互斥的,一个线程拿到了这把锁,另一个线程也想获取,就需要阻塞等待
  2. 不可抢占:一个线程拿到了锁之后,只能主动解锁,不能让别的线程强行把锁抢走
  3. 请求保持:一个线程拿到了锁 A 之后,在持有 A 的前提下尝试获取锁 B
  4. 循环等待 / 环路等待

一旦出现死锁,线程就“卡住了”,无法继续工作。所以死锁属于程序中最严重的一类 bug !

4.1.4.3 避免死锁的方法

制定一定的规则,避免循环等待:指定加锁顺序。
例如:给五把锁分别进行编号,约定每个线程获取锁的时候,先获取编号小的锁,在获取编号大的锁。

在这里插入图片描述

4.2 volatile 关键字

volatile 关键字修饰的变量,能保证内存可见性。强制读写内存,虽然降低了速度,但能保证准确性。

5. Java 标准库中的线程安全类

  1. 线程不安全,这些类可能涉及到多线程修改共享数据,又没有任何加锁措施:
    ArrayList, LinkedList, HashMap, TreeMap, HashSet, TreeSet, StringBuilder
  2. 线程安全,这些类自带锁:
    Vector(不推荐使用), HashTable(不推荐使用), ConcurrentHashMap, StringBuffer(不推荐使用)
  3. 没加锁但线程安全:String