【通用开发】线程安全问题

介绍了Android开发中实现线程安全的几种方式
线程的状态
一个 Thread 线程的生命周期:

各种状态一目了然,值得一提的是”blocked”这个状态:线程在Running的过程中可能会遇到阻塞(Blocked)情况
- 调用
join()和sleep()方法,sleep()时间结束或被打断,join()中断去执行其他线程,IO完成都会回到Runnable状态,等待JVM的调度。 - 调用
wait(),使该线程处于等待池(wait blocked pool),直到notify()/notifyAll(),线程被唤醒被放到锁定池(lock blocked pool),释放同步锁使线程回到可运行状态(Runnable) - 对
Running状态的线程加同步锁(Synchronized)使其进入(lock blocked pool),同步锁被释放进入可运行状态(Runnable)。
此外,在 runnable 状态的线程是处于被调度的线程,此时的调度顺序是不一定的。 Thread 类中的 yield() 方法可以让一个running状态的线程转入runnable。
为什么会有线程安全问题
如果不使用任何同步机制,在多线程中读写同一个变量。那么,程序的结果是难以预料的。
主要原因有一下几点:
- 简单的读写不是原子操作
- CPU 可能会调整指令的执行顺序
- 在 CPU cache 的影响下,一个 CPU 执行了某个指令,不会立即被其它 CPU 看见
1. 原子操作
原子操作(Atomic Operation)是指在多线程或并发编程中,不可被中断的一个或一系列操作。这些操作 要么全部执行完成,要么完全不执行 ,不会出现执行到一半被其他线程干扰的情况,从而保证操作的完整性和一致性。
原子操作在执行过程中不会被其他线程或进程打断。并且操作完成后,结果会立即对其他线程可见(通常由硬件或底层内存模型保证)。
非原子操作的影响
举例:
int64_t i = 0; // global variable
Thread-1: Thread-2:
i++; std::cout << i;
C++ 并不保证 i++ 是原子操作。从汇编的角度看,读写内存的操作一般分为三步:
- 将内存单元读到 cpu 寄存器
- 修改寄存器中的值
- 将寄存器中的值回写入对应的内存单元
进一步,有的 CPU Architecture, 64 位数据(int64_t)在内存和寄存器之间的读写需要两条指令。
这就导致了 i++ 操作在 cpu 的角度是一个多步骤的操作。所以 Thread-2 读到的可能是一个中间状态。
2. CPU重排的影响
为了优化程序的执行性能,编译器和 CPU 可能会 调整指令的执行顺序 。为阐述这一点,下面的例子中,让我们假设所有操作都是原子操作:
int x = 0; // global variable
int y = 0; // global variable
Thread-1: Thread-2:
x = 100; while (y != 200) {}
y = 200; std::cout << x;
如果 CPU 没有乱序执行指令,那么 Thread-2 将输出 100。然而,对于 Thread-1 来说,x = 100; 和 y = 200; 这 两个语句之间没有依赖关系 ,因此,CPU可能会允许调整语句的执行顺序。
在这种情况下,Thread-2 的打印,有可能是 0 也有可能是 100。
2. CPU CACHE的影响
CPU cache 也会影响到程序的行为。下面的例子中,假设从时间上来讲,A 操作先于 B 操作发生:
int x = 0; // global variable
Thread-1: Thread-2:
x = 100; // A std::cout << x; // B
x = 100; ,这个看似简短的语句,在 CPU 的实际执行步骤为:
- 取指令:CPU从指令缓存中读取 mov 指令。
- 解码:解码指令,识别操作(写入内存)和操作数(地址
[x]和值 100)。 - 内存访问。计算变量 x 的内存地址。若 x 不在缓存中,触发缓存加载(Cache Miss)。
- 数据写入:将值 100 写入 x 的内存地址。
- 缓存同步:更新缓存线,可能通过缓存一致性协议(如MESI)通知其他核心。
尽管从时间上来讲,A 先于 B,但 CPU cache 的影响下, Thread-2 不能保证立即看到 A 操作的结果,所以 Thread-2 可能输出 0 或 100。
Java中常见实现线程安全的操作
1. 使用 final 属性 (Immutability)
声明一个字段为 final 后,它的值在对象构造完成后就不能再被改变。如果一个对象的所有字段都是 final 并且它们引用的对象(如果是引用类型)也是不可变的,那么这个对象就是不可变对象 (Immutable Object)。不可变对象在多线程环境中天然是线程安全的,因为它们的状态不会被任何线程修改。
优点是简单、安全,是实现线程安全的“黄金法则”。应用场景比较有限。
代码举例:
public final class ImmutablePoint {
// 两个final属性,它们的值在构造函数中确定后不可更改
private final int x;
private final int y;
public ImmutablePoint(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
// 注意:没有提供任何修改x或y的方法(setter)
}
// 在多线程中使用 ImmutablePoint 对象时,无需任何同步措施。
2. ThreadLocal 线程隔离
ThreadLocal 为每个使用该变量的线程都提供了一个独立的、线程本地的副本。这样,一个线程对变量的修改不会影响到其他线程,从而实现了线程间的隔离,避免了共享资源的竞争。
适用于保存用户会话信息、数据库连接、事务上下文等,这些信息通常只需要在当前线程内共享。
代码举例:
public class ThreadLocalExample {
// 创建一个 ThreadLocal 实例
private static final ThreadLocal<String> threadLocalUser = new ThreadLocal<>();
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
String threadName = Thread.currentThread().getName();
// 1. 设置当前线程的本地变量
threadLocalUser.set(threadName + "'s Data");
System.out.println(threadName + " set data: " + threadLocalUser.get());
// 输出: A set data: A's Data
try {
Thread.sleep(50); // 模拟耗时操作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 2. 获取当前线程的本地变量
System.out.println(threadName + " get data: " + threadLocalUser.get());
// 输出: A get data: A's Data
// 3. 推荐在线程结束时移除,避免内存泄漏(尤其是在线程池中)
threadLocalUser.remove();
};
Thread threadA = new Thread(task, "Thread-A");
Thread threadB = new Thread(task, "Thread-B");
threadA.start();
threadB.start();
}
}
/*
可能的输出(Thread-A 和 Thread-B 的数据互不影响):
Thread-A set data: Thread-A's Data
Thread-B set data: Thread-B's Data
Thread-A get data: Thread-A's Data
Thread-B get data: Thread-B's Data
*/
3. volatile 关键字
volatile 保证了对变量读写的可见性和操作的有序性,但 不保证原子性 。
- 可见性 (Visibility): 当一个线程修改了
volatile变量的值,新值会立即同步回主内存;当其他线程读取该变量时,会从主内存中重新获取最新值,而不是使用自己的工作内存副本。 - 有序性 (Ordering): 禁止 JVM 对
volatile变量的读写操作进行重排序。
volatile 变量的读写操作仍然在 CPU 缓存中进行,但 JVM 会在这些操作周围插入内存屏障 (Memory Barriers),来保证数据同步。
- 写操作之后会插入一个 Store Barrier (写屏障) 。这个屏障会强制要求 CPU 将本地缓存中的最新值立即刷新(写入)到主内存。同时,它还会使其他 CPU 核心中该变量的缓存副本失效(Invalidate)。
- 在读操作之前会插入一个 Load Barrier (读屏障) 。这个屏障会要求 CPU 重新从主内存中加载最新的值到本地缓存,而不是使用可能已过时的本地缓存副本。
适用于修饰状态标记 (flag) 或一次写、多次读的共享变量,但不适用于依赖当前值进行计算的场景(例如 i++)。
代码举例:
public class VolatileExample {
// 状态标志,一个线程修改后,其他线程需要立即看到最新值
private volatile boolean isRunning = true;
public void stop() {
isRunning = false; // 线程 A 修改
System.out.println(Thread.currentThread().getName() + " set isRunning to false");
}
public void runLoop() {
// 线程 B 持续读取 isRunning
while (isRunning) {
// ... 执行任务
}
System.out.println(Thread.currentThread().getName() + " loop stopped.");
}
public static void main(String[] args) throws InterruptedException {
VolatileExample example = new VolatileExample();
// 线程 B 启动循环
Thread runnerThread = new Thread(example::runLoop, "Runner-Thread");
runnerThread.start();
// 主线程等待一段时间,让 runnerThread 运行起来
Thread.sleep(100);
// 线程 A (主线程) 停止循环
example.stop();
runnerThread.join(); // 等待 runnerThread 结束
}
}
插入:乐观锁和悲观锁
悲观锁 (Pessimistic Locking)
假设最坏的情况,认为数据随时可能被其他线程或进程修改,所以 每次访问数据时都会先给数据上锁 ,防止其他人在自己操作期间修改数据。Java中的 synchronized , ReentrantLock 都属于悲观锁。
乐观锁 (Optimistic Locking)
假设最好的情况,认为数据被修改的概率很低,所以它不会在访问数据时加锁,而是 在更新数据时才去检查在此期间有没有人修改过数据 。
配合 CAS(Compare and Swap) 机制,这是 CPU 指令级别的原子操作,是 Java 中实现乐观锁的基石。CAS 操作是一个由 CPU 指令保证的原子操作。
在写值时会先读取一遍当前内存中是否还是原值,如果是则执行写入,如果不是,则放弃修改。
Java 并发包中很多原子类(如 AtomicInteger)就是基于 CAS 乐观锁思想实现的。
4. synchronized 关键字
synchronized 是一种内置的 互斥锁 (Intrinsic Lock) 机制,它确保同一时刻只有一个线程可以执行被它保护的代码块或方法。它保证了操作的原子性、可见性和有序性。
使用方式:
- 同步实例方法: 锁住当前实例对象 (
this)。 - 同步静态方法: 锁住当前类的
Class对象。 - 同步代码块: 锁住括号内指定的对象。
代码举例 (同步代码块):
public class SynchronizedExample {
private int count = 0;
// 使用一个私有的 final 对象作为锁,避免外部干扰
private final Object lock = new Object();
public void increment() {
// 只有获取到 lock 对象的锁的线程才能进入代码块
synchronized (lock) {
// 这是一个复合操作 (读->改->写),必须是原子性的
count++;
}
}
// 也可以同步方法: public synchronized void increment() { count++; }
public int getCount() {
return count;
// 实际上,为了保证可见性,这里读取操作也应该同步,或者将 count 声明为 volatile。
// 为了演示 synchronized 保证原子性,此处简化。
}
}
5. Lock 加锁 (J.U.C Lock Interface)
Lock 接口(如 ReentrantLock)是 Java 5 引入的,属于 java.util.concurrent.locks 包下的显式锁机制。它提供了比 synchronized 更灵活、更强大的功能,例如:
- 可中断锁:
lock.lockInterruptibly() - 尝试锁:
lock.tryLock() - 定时锁:
lock.tryLock(long timeout, TimeUnit unit) - 公平锁/非公平锁: 可以在构造函数中指定。
代码举例 (ReentrantLock):
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockExample {
private int count = 0;
// 创建一个可重入锁实例
private final Lock lock = new ReentrantLock();
public void increment() {
// 1. 获取锁
lock.lock();
try {
// 确保同步代码块中的操作是原子性的
count++;
} finally {
// 2. 释放锁。注意:必须放在 finally 块中,确保在发生异常时也能释放锁
lock.unlock();
}
}
public int getCount() {
return count;
}
}
常见集合类容器的线程安全分析
Java 中的集合类主要分为 非线程安全 (Non-thread-safe)、线程安全 (Thread-safe) 的同步容器和 并发容器 (Concurrent) 三类。
1. 列表类 (List)
ArrayList 是一个普通的、非同步的类,它的方法(如 add(), get(), remove() 等)都没有使用 synchronized 关键字进行同步控制。在多线程环境下并发操作(如增删改)会导致数据不一致或抛出 ConcurrentModificationException。
为什么说它不是线程安全的?
如果在多线程环境中,多个线程同时对同一个 ArrayList 实例进行修改操作(例如一个线程在 add(),另一个线程在 remove()),就可能出现以下问题:
- 数据不一致(Data Corruption):例如,两个线程同时尝试添加元素,可能会导致底层数组的数据混乱。
- 竞态条件(Race Condition):可能导致
ArrayList的内部状态(如记录大小的size变量)被错误更新。 - 抛出异常:最常见的情况是在遍历(迭代)时,另一个线程修改了列表结构,会抛出
ConcurrentModificationException。
如何使 List 线程安全?
如果您需要在多线程环境中使用一个类似 ArrayList 的列表,有以下几种线程安全的替代方案:
使用同步包装器(Synchronized Wrapper):
List<String> synchronizedList = Collections.synchronizedList(new ArrayList<String>());简单易用。但是性能较低,因为它对所有操作都是通过锁住整个列表对象来实现的,在高并发下会有性能瓶颈。
使用 JUC 包中的并发列表(推荐):
List<String> safeList = new CopyOnWriteArrayList<String>(); public void addItem(String item) { safeList.add(item); // 线程安全 }Java原生提供的
CopyOnWriteArrayList性能更高,尤其是在 读多写少 的场景。其采用了 写时复制 的策略。当列表需要被修改时(add、set等),它会创建一个新的底层数组副本,修改在新副本上进行,然后将列表的引用指向新副本。读取操作(get)则始终在旧的数组上进行,不需要加锁。
2. Map类
HashMap 是最常用的 Map 实现。基于哈希表(数组+链表/红黑树)实现。它的键和值都允许为 null。
HashMap 是 Java 中最常用的 Map 实现,它在设计时主要关注的是性能(查找、插入等操作的平均时间复杂度为 $O(1)$),而不是线程安全。它 适用于单线程环境 。性能最高 O(1) 级别,但在并发环境下(多线程同时读写)会引发问题,例如多个线程同时对同一个 HashMap 实例进行修改操作(put()、remove() 等),会引发严重的问题,包括:
- 数据丢失或不一致: 多个线程同时操作同一个桶(Bucket)时,可能导致数据覆盖或链表结构混乱。
- 死循环 (Infinite Loop): 在
HashMap扩容(resize())的过程中,链表会被重新分配到新的数组中。在并发修改的情况下,可能会出现链表节点相互指向的情况,形成环状结构。当另一个线程尝试遍历这个环时,就会导致 CPU 占用 100% 的死循环,使程序彻底挂死。这个问题在早期的 JDK 版本中尤其常见。 - 抛出异常: 类似
ArrayList,在迭代过程中进行结构性修改,也会抛出ConcurrentModificationException。
Map线程安全的替代方案
如果需要在多线程环境中使用一个 Map 结构,类似于List的策略,可以使用以下线程安全的两种方案:
Collections.synchronizedMap()包装一个HashMap可以实现线程安全,但是性能较差。通过包装HashMap,使用锁住整个对象的方式实现同步。适用于对性能要求不高的多线程环境。ConcurrentHashMap是一个高性能的Map类。采用更细粒度的锁机制(如 Java 8 采用 CAS + Synchronized 锁住单个桶)。读操作通常是无锁的。绝大多数多线程 Map 场景的首选。 提供了高并发下的高性能。