arrow_back 返回

线程安全问题

Stephen
Stephen
Android / AI 应用开发者 · 发布于 2023年8月13日
线程安全问题

线程安全问题

线程的状态

一个 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中的 synchronized , ReentrantLock 都属于悲观锁。

乐观锁 (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 性能更高,尤其是在 读多写少 的场景。其采用了 写时复制 的策略。当列表需要被修改时(add、set 等),它会创建一个新的底层数组副本,修改在新副本上进行,然后将列表的引用指向新副本。读取操作(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 场景的首选。 提供了高并发下的高性能。
#通用开发 #android