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

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

介绍了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++ 是原子操作。从汇编的角度看,读写内存的操作一般分为三步:

  1. 将内存单元读到 cpu 寄存器
  2. 修改寄存器中的值
  3. 将寄存器中的值回写入对应的内存单元

进一步,有的 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 的实际执行步骤为:

  1. 取指令:CPU从指令缓存中读取 mov 指令。
  2. 解码:解码指令,识别操作(写入内存)和操作数(地址 [x] 和值 100)。
  3. 内存访问。计算变量 x 的内存地址。若 x 不在缓存中,触发缓存加载(Cache Miss)。
  4. 数据写入:将值 100 写入 x 的内存地址。
  5. 缓存同步:更新缓存线,可能通过缓存一致性协议(如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中的 synchronizedReentrantLock 都属于悲观锁。

乐观锁 (Optimistic Locking)

假设最好的情况,认为数据被修改的概率很低,所以它不会在访问数据时加锁,而是 在更新数据时才去检查在此期间有没有人修改过数据

配合 CAS(Compare and Swap) 机制,这是 CPU 指令级别的原子操作,是 Java 中实现乐观锁的基石。CAS 操作是一个由 CPU 指令保证的原子操作

在写值时会先读取一遍当前内存中是否还是原值,如果是则执行写入,如果不是,则放弃修改。

Java 并发包中很多原子类(如 AtomicInteger)就是基于 CAS 乐观锁思想实现的。

4. synchronized 关键字

synchronized 是一种内置的 互斥锁 (Intrinsic Lock) 机制,它确保同一时刻只有一个线程可以执行被它保护的代码块或方法。它保证了操作的原子性可见性有序性

使用方式:

  1. 同步实例方法: 锁住当前实例对象 (this)。
  2. 同步静态方法: 锁住当前类的 Class 对象。
  3. 同步代码块: 锁住括号内指定的对象。

代码举例 (同步代码块):

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()),就可能出现以下问题:

  1. 数据不一致(Data Corruption):例如,两个线程同时尝试添加元素,可能会导致底层数组的数据混乱。
  2. 竞态条件(Race Condition):可能导致 ArrayList 的内部状态(如记录大小的 size 变量)被错误更新。
  3. 抛出异常:最常见的情况是在遍历(迭代)时,另一个线程修改了列表结构,会抛出 ConcurrentModificationException
如何使 List 线程安全?

如果您需要在多线程环境中使用一个类似 ArrayList 的列表,有以下几种线程安全的替代方案:

  1. 使用同步包装器(Synchronized Wrapper):

    List<String> synchronizedList = Collections.synchronizedList(new ArrayList<String>());
    

    简单易用。但是性能较低,因为它对所有操作都是通过锁住整个列表对象来实现的,在高并发下会有性能瓶颈。

  2. 使用 JUC 包中的并发列表(推荐):

    List<String> safeList = new CopyOnWriteArrayList<String>();
    
    public void addItem(String item) {
        safeList.add(item); // 线程安全
    }
    

    Java原生提供的 CopyOnWriteArrayList 性能更高,尤其是在 读多写少 的场景。其采用了 写时复制 的策略。当列表需要被修改时(addset 等),它会创建一个新的底层数组副本,修改在新副本上进行,然后将列表的引用指向新副本。读取操作(get)则始终在旧的数组上进行,不需要加锁。

2. Map类

HashMap 是最常用的 Map 实现。基于哈希表(数组+链表/红黑树)实现。它的键和值都允许为 null。

HashMap 是 Java 中最常用的 Map 实现,它在设计时主要关注的是性能(查找、插入等操作的平均时间复杂度为 $O(1)$),而不是线程安全。它 适用于单线程环境 。性能最高 O(1) 级别,但在并发环境下(多线程同时读写)会引发问题,例如多个线程同时对同一个 HashMap 实例进行修改操作(put()remove() 等),会引发严重的问题,包括:

  1. 数据丢失或不一致: 多个线程同时操作同一个桶(Bucket)时,可能导致数据覆盖或链表结构混乱。
  2. 死循环 (Infinite Loop):HashMap 扩容(resize())的过程中,链表会被重新分配到新的数组中。在并发修改的情况下,可能会出现链表节点相互指向的情况,形成环状结构。当另一个线程尝试遍历这个环时,就会导致 CPU 占用 100% 的死循环,使程序彻底挂死。这个问题在早期的 JDK 版本中尤其常见。
  3. 抛出异常: 类似 ArrayList,在迭代过程中进行结构性修改,也会抛出 ConcurrentModificationException
Map线程安全的替代方案

如果需要在多线程环境中使用一个 Map 结构,类似于List的策略,可以使用以下线程安全的两种方案:

  • Collections.synchronizedMap() 包装一个HashMap可以实现线程安全,但是性能较差。通过包装 HashMap,使用锁住整个对象的方式实现同步。适用于对性能要求不高的多线程环境。
  • ConcurrentHashMap 是一个高性能的Map类。采用更细粒度的锁机制(如 Java 8 采用 CAS + Synchronized 锁住单个桶)。读操作通常是无锁的。绝大多数多线程 Map 场景的首选。 提供了高并发下的高性能。