MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java内存的线程安全问题

2023-11-013.5k 阅读

Java内存模型基础

在深入探讨Java内存的线程安全问题之前,我们需要先了解Java内存模型(Java Memory Model,JMM)的基础知识。Java内存模型是Java虚拟机规范中定义的一种抽象的内存模型,它描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中取出变量这样的底层细节。

主内存与工作内存

JMM规定所有的变量都存储在主内存(Main Memory)中,这里的变量包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享。每个线程都有自己独立的工作内存(Working Memory),线程的工作内存保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

例如,假设有两个线程 ThreadAThreadB 同时访问共享变量 countcount 存储在主内存中,ThreadAThreadB 各自的工作内存中有 count 的副本。当 ThreadAcount 进行修改时,它首先修改的是自己工作内存中的 count 副本,然后才会将修改后的值刷新到主内存中。而 ThreadB 如果要看到 ThreadAcount 的修改,必须从主内存中重新读取 count 的值到自己的工作内存。

内存间交互操作

JMM定义了8种操作来完成主内存与工作内存之间的交互:

  1. lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  2. unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  3. read(读取):作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  4. load(载入):作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  5. use(使用):作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  7. store(存储):作用于工作内存的变量,把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  8. write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

这些操作必须满足一些规则,例如,不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须同步到主内存中;不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存等。

线程安全问题的本质

理解了Java内存模型后,我们来探讨线程安全问题的本质。线程安全问题通常发生在多个线程同时访问和修改共享资源的时候。由于多个线程对共享变量的操作可能是交叉进行的,这就可能导致数据不一致或其他未预期的结果。

竞态条件(Race Condition)

竞态条件是线程安全问题的核心表现形式。当多个线程在没有适当同步的情况下访问和修改共享资源时,就会出现竞态条件。例如,考虑一个简单的计数器类:

public class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

如果有多个线程同时调用 increment 方法,就可能出现竞态条件。count++ 操作实际上包含了三个步骤:读取 count 的值、将值加1、将结果写回 count。假设线程A读取了 count 的值为10,此时线程B也读取了 count 的值为10(因为还没来得及更新)。然后线程A将值加1并写回,count 变为11。接着线程B也将值加1并写回,count 仍然是11,而不是预期的12。这就是因为竞态条件导致的结果错误。

可见性问题

除了竞态条件,可见性问题也是线程安全的一个重要方面。由于每个线程都有自己的工作内存,一个线程对共享变量的修改可能不会立即被其他线程看到。例如:

public class VisibilityExample {
    private static boolean flag = false;

    public static void main(String[] args) {
        new Thread(() -> {
            while (!flag) {
                // 线程1在等待flag变为true
            }
            System.out.println("线程1结束");
        }).start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        flag = true;
        System.out.println("主线程设置flag为true");
    }
}

在这个例子中,主线程设置 flagtrue 后,线程1可能永远不会结束,因为线程1的工作内存中的 flag 副本可能没有及时更新,它看不到主线程对 flag 的修改。这就是可见性问题导致的线程无法按预期执行。

解决线程安全问题的方式

为了解决Java内存中的线程安全问题,Java提供了多种机制。

同步关键字synchronized

synchronized 关键字可以用来修饰方法或代码块,它的作用是确保在同一时刻,只有一个线程能够执行被 synchronized 修饰的代码。

  1. 修饰实例方法:当 synchronized 修饰实例方法时,锁住的是当前对象实例。例如:
public class SynchronizedInstanceMethod {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

在这个例子中,increment 方法被 synchronized 修饰,这意味着当一个线程调用 increment 方法时,其他线程如果也想调用 increment 方法,必须等待当前线程执行完 increment 方法并释放锁。

  1. 修饰静态方法:当 synchronized 修饰静态方法时,锁住的是当前类的 Class 对象。例如:
public class SynchronizedStaticMethod {
    private static int count = 0;

    public static synchronized void increment() {
        count++;
    }

    public static int getCount() {
        return count;
    }
}

这里,increment 方法是静态的且被 synchronized 修饰,所有线程调用 SynchronizedStaticMethod.increment() 方法时,都需要获取 SynchronizedStaticMethod.class 对象的锁。

  1. 修饰代码块synchronized 还可以修饰代码块,通过指定锁对象来控制同步范围。例如:
public class SynchronizedBlock {
    private int count = 0;
    private final Object lock = new Object();

    public void increment() {
        synchronized (lock) {
            count++;
        }
    }

    public int getCount() {
        return count;
    }
}

在这个例子中,increment 方法中的代码块被 synchronized 修饰,锁对象是 lock。只有获取到 lock 对象锁的线程才能执行代码块中的 count++ 操作。

volatile关键字

volatile 关键字主要用于解决可见性问题。当一个变量被声明为 volatile 时,它会确保对该变量的修改会立即刷新到主内存,并且其他线程在读取该变量时会直接从主内存中读取,而不是从自己的工作内存中读取旧值。

例如,我们修改前面的 VisibilityExample 代码:

public class VolatileVisibilityExample {
    private static volatile boolean flag = false;

    public static void main(String[] args) {
        new Thread(() -> {
            while (!flag) {
                // 线程1在等待flag变为true
            }
            System.out.println("线程1结束");
        }).start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        flag = true;
        System.out.println("主线程设置flag为true");
    }
}

此时,当主线程设置 flagtrue 后,线程1会立即看到这个修改,从而结束循环。

需要注意的是,volatile 关键字并不能解决竞态条件问题。例如,对于 count++ 这样的复合操作,即使 count 被声明为 volatile,仍然可能出现竞态条件,因为 count++ 不是原子操作。

原子类(Atomic Classes)

Java提供了一系列原子类,如 AtomicIntegerAtomicLongAtomicBoolean 等,它们可以在不使用锁的情况下实现线程安全的操作。这些原子类利用了处理器的CAS(Compare - and - Swap)指令,保证了操作的原子性。

例如,使用 AtomicInteger 来实现一个线程安全的计数器:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();
    }

    public int getCount() {
        return count.get();
    }
}

在这个例子中,incrementAndGet 方法是原子操作,不会出现竞态条件,即使多个线程同时调用 increment 方法,也能保证 count 的值正确递增。

线程安全与性能考量

在解决线程安全问题时,我们也需要考虑性能因素。不同的同步机制对性能的影响是不同的。

synchronized的性能分析

synchronized 关键字虽然能有效解决线程安全问题,但它是一种比较重量级的同步机制。当一个线程获取锁时,其他线程必须等待,这会导致线程上下文切换等开销。特别是在高并发环境下,如果锁的竞争非常激烈,synchronized 可能会成为性能瓶颈。

例如,在一个有大量线程频繁访问的方法上使用 synchronized 修饰:

public class SynchronizedPerformanceExample {
    private int data = 0;

    public synchronized void updateData() {
        for (int i = 0; i < 10000; i++) {
            data++;
        }
    }
}

在这种情况下,由于锁的竞争,会有很多线程等待,从而降低了整体的执行效率。

volatile与原子类的性能优势

volatile 关键字和原子类在性能上通常比 synchronized 更有优势。volatile 主要解决可见性问题,它不会像锁那样阻塞线程,因此在只需要保证可见性的场景下,使用 volatile 可以提高性能。

原子类利用CAS操作,在不需要锁的情况下实现原子操作,避免了线程阻塞和上下文切换的开销。例如,在高并发的计数器场景中,使用 AtomicInteger 比使用 synchronized 修饰的计数器方法性能更好。

线程安全问题的实际场景与案例分析

了解了理论知识和解决方法后,我们来看一些实际场景中的线程安全问题案例。

银行转账场景

假设我们有一个银行转账的场景,从一个账户向另一个账户转账。

public class BankAccount {
    private double balance;

    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    public double getBalance() {
        return balance;
    }

    public void deposit(double amount) {
        balance += amount;
    }

    public void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
        }
    }
}

public class TransferExample {
    public static void main(String[] args) {
        BankAccount account1 = new BankAccount(1000);
        BankAccount account2 = new BankAccount(500);

        Thread transferThread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                account1.withdraw(10);
                account2.deposit(10);
            }
        });

        Thread transferThread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                account1.withdraw(10);
                account2.deposit(10);
            }
        });

        transferThread1.start();
        transferThread2.start();

        try {
            transferThread1.join();
            transferThread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("账户1余额: " + account1.getBalance());
        System.out.println("账户2余额: " + account2.getBalance());
    }
}

在这个例子中,如果不进行同步,可能会出现账户余额计算错误的情况。比如,当两个线程同时执行 account1.withdraw(10) 时,可能会读取到相同的余额,导致取款操作重复执行,最终账户余额不正确。

我们可以使用 synchronized 关键字来解决这个问题:

public class SynchronizedBankAccount {
    private double balance;

    public SynchronizedBankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    public double getBalance() {
        return balance;
    }

    public synchronized void deposit(double amount) {
        balance += amount;
    }

    public synchronized void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
        }
    }
}

public class SynchronizedTransferExample {
    public static void main(String[] args) {
        SynchronizedBankAccount account1 = new SynchronizedBankAccount(1000);
        SynchronizedBankAccount account2 = new SynchronizedBankAccount(500);

        Thread transferThread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                account1.withdraw(10);
                account2.deposit(10);
            }
        });

        Thread transferThread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                account1.withdraw(10);
                account2.deposit(10);
            }
        });

        transferThread1.start();
        transferThread2.start();

        try {
            transferThread1.join();
            transferThread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("账户1余额: " + account1.getBalance());
        System.out.println("账户2余额: " + account2.getBalance());
    }
}

通过对 depositwithdraw 方法使用 synchronized 修饰,确保了在同一时刻只有一个线程能够执行这些方法,从而保证了账户余额的正确性。

单例模式中的线程安全

单例模式是一种常用的设计模式,用于确保一个类只有一个实例。在多线程环境下,实现单例模式需要考虑线程安全问题。

  1. 饿汉式单例
public class EagerSingleton {
    private static final EagerSingleton instance = new EagerSingleton();

    private EagerSingleton() {}

    public static EagerSingleton getInstance() {
        return instance;
    }
}

饿汉式单例在类加载时就创建了实例,因此天生是线程安全的,因为类加载过程是由JVM保证线程安全的。

  1. 懒汉式单例(非线程安全)
public class LazySingleton {
    private static LazySingleton instance;

    private LazySingleton() {}

    public static LazySingleton getInstance() {
        if (instance == null) {
            instance = new LazySingleton();
        }
        return instance;
    }
}

懒汉式单例在第一次调用 getInstance 方法时才创建实例。但这种实现是非线程安全的,如果两个线程同时调用 getInstance 方法,并且都判断 instancenull,就会创建两个实例。

  1. 懒汉式单例(线程安全,使用synchronized)
public class SynchronizedLazySingleton {
    private static SynchronizedLazySingleton instance;

    private SynchronizedLazySingleton() {}

    public static synchronized SynchronizedLazySingleton getInstance() {
        if (instance == null) {
            instance = new SynchronizedLazySingleton();
        }
        return instance;
    }
}

通过在 getInstance 方法上使用 synchronized 关键字,确保了在同一时刻只有一个线程能够进入方法创建实例,从而保证了线程安全。但这种方式在每次调用 getInstance 方法时都需要获取锁,性能较低。

  1. 双重检查锁定(DCL)实现线程安全懒汉式单例
public class DoubleCheckedLockingSingleton {
    private volatile static DoubleCheckedLockingSingleton instance;

    private DoubleCheckedLockingSingleton() {}

    public static DoubleCheckedLockingSingleton getInstance() {
        if (instance == null) {
            synchronized (DoubleCheckedLockingSingleton.class) {
                if (instance == null) {
                    instance = new DoubleCheckedLockingSingleton();
                }
            }
        }
        return instance;
    }
}

双重检查锁定通过两次 if (instance == null) 判断,在第一次判断时,如果 instance 不为 null,直接返回实例,避免了每次都获取锁的开销。而第二次判断是在获取锁之后,确保在多线程环境下只有一个实例被创建。这里 instance 被声明为 volatile,是为了防止指令重排序导致的问题。在创建 instance = new DoubleCheckedLockingSingleton() 时,可能会出现指令重排序,先分配内存空间,然后初始化对象,最后将 instance 指向分配的内存空间。如果没有 volatile,另一个线程可能在 instance 还未初始化完成时就看到了非 nullinstance,从而导致使用未初始化完全的对象。

深入理解Java内存模型与线程安全的关系

Java内存模型与线程安全密切相关,它为解决线程安全问题提供了理论基础和规范。

重排序与线程安全

重排序是指编译器和处理器为了优化程序性能,在不改变程序单线程语义的前提下,对指令进行重新排序的行为。重排序可能会导致线程安全问题。

例如,在双重检查锁定的单例模式中,如果没有 volatile 修饰 instance,可能会出现重排序问题。假设线程A执行 instance = new DoubleCheckedLockingSingleton(),在重排序的情况下,可能先分配内存空间给 instance,然后将 instance 指向该内存空间(此时 instance 不为 null),最后才初始化对象。如果此时线程B判断 instance 不为 null 并直接使用 instance,就会使用到未初始化完全的对象,导致程序出错。

JMM通过内存屏障(Memory Barrier)来禁止特定类型的重排序,从而保证线程安全。例如,volatile 关键字会在写操作后插入一个Store Memory Barrier,在读操作前插入一个Load Memory Barrier,确保了 volatile 变量的写操作先于读操作,避免了重排序带来的问题。

happens - before原则

happens - before原则是JMM中定义的一种偏序关系,它用于判断在一个线程中观察另一个线程对共享变量的写操作是否可见。如果一个操作 A happens - before 另一个操作 B,那么操作 A 产生的影响能被操作 B 观察到。

  1. 程序顺序规则:在一个线程内,按照程序代码顺序,前面的操作 happens - before 后续的操作。例如:
int a = 1; // 操作1
int b = a + 1; // 操作2

操作1 happens - before 操作2,操作2能看到操作1对 a 的赋值。

  1. 管程锁定规则:一个unlock操作 happens - before 后面对同一个锁的lock操作。例如:
synchronized (lock) {
    // 临界区
} // unlock操作
// 其他代码
synchronized (lock) {
    // 另一个临界区,这里的lock操作能看到前面unlock操作对共享变量的修改
}
  1. volatile变量规则:对一个volatile变量的写操作 happens - before 后面对这个变量的读操作。例如:
volatile int value;
// 线程1
value = 10; // 写操作
// 线程2
int result = value; // 读操作,能看到线程1对value的写操作
  1. 线程启动规则:Thread对象的start() 方法 happens - before 此线程的每一个动作。例如:
Thread thread = new Thread(() -> {
    // 线程内的操作
});
thread.start(); // 启动线程,此操作happens - before线程内的所有操作
  1. 线程终止规则:线程中的所有操作 happens - before 对此线程的终止检测。例如:
Thread thread = new Thread(() -> {
    // 线程内的操作
});
thread.start();
try {
    thread.join(); // 等待线程终止,线程内的所有操作happens - before这里的检测
} catch (InterruptedException e) {
    e.printStackTrace();
}
  1. 线程中断规则:对线程interrupt() 方法的调用 happens - before 被中断线程的代码检测到中断事件的发生。例如:
Thread thread = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // 线程内操作
    }
});
thread.start();
thread.interrupt(); // 调用interrupt方法,此操作happens - before线程内对中断的检测
  1. 对象终结规则:一个对象的初始化完成(构造函数执行结束) happens - before 它的finalize() 方法的开始。

happens - before原则为我们分析和解决线程安全问题提供了一种重要的工具,通过判断操作之间的happens - before关系,我们可以确定共享变量的可见性和操作的顺序性,从而编写正确的多线程程序。

多线程环境下的内存泄漏与线程安全

在多线程环境下,除了常见的线程安全问题,内存泄漏也是一个需要关注的方面,并且它与线程安全存在一定的关联。

线程局部变量与内存泄漏

线程局部变量(Thread - Local Variables)通过 ThreadLocal 类来实现。ThreadLocal 为每个线程提供了独立的变量副本,从而避免了线程安全问题。然而,如果使用不当,ThreadLocal 也可能导致内存泄漏。

例如,假设有一个 ThreadLocal 变量用于存储数据库连接:

public class DatabaseConnectionHolder {
    private static final ThreadLocal<Connection> connectionHolder = ThreadLocal.withInitial(() -> {
        // 创建数据库连接
        try {
            return DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "user", "password");
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    });

    public static Connection getConnection() {
        return connectionHolder.get();
    }

    public static void closeConnection() {
        Connection connection = connectionHolder.get();
        if (connection != null) {
            try {
                connection.close();
            } catch (SQLException e) {
                e.printStackTrace();
            }
            connectionHolder.remove();
        }
    }
}

在这个例子中,如果在使用完数据库连接后没有调用 connectionHolder.remove() 方法,ThreadLocal 中的连接对象将不会被垃圾回收,即使线程已经结束,这就导致了内存泄漏。因为每个线程的 ThreadLocal 实例都持有对连接对象的引用,只要 ThreadLocal 实例存在,连接对象就不会被回收。

线程池与内存泄漏

线程池在多线程编程中被广泛使用。如果线程池中的线程在执行任务时持有对大对象的引用,并且这些线程被长时间复用,而任务执行完成后没有及时释放这些引用,就可能导致内存泄漏。

例如,假设有一个线程池处理图片处理任务,每个任务需要加载一张大图片:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ImageProcessingTask implements Runnable {
    private byte[] largeImage;

    public ImageProcessingTask(byte[] imageData) {
        this.largeImage = imageData;
    }

    @Override
    public void run() {
        // 图片处理逻辑
        // 处理完成后没有释放largeImage引用
    }
}

public class ThreadPoolMemoryLeakExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 100; i++) {
            byte[] largeImageData = new byte[1024 * 1024 * 10]; // 10MB图片数据
            executorService.submit(new ImageProcessingTask(largeImageData));
        }
        executorService.shutdown();
    }
}

在这个例子中,ImageProcessingTask 中的 largeImage 引用在任务执行完成后没有被释放,而线程池中的线程会复用,这就导致这些大图片对象一直无法被垃圾回收,最终可能导致内存泄漏。

为了避免线程池导致的内存泄漏,在任务执行完成后,应该及时释放对大对象的引用,或者使用弱引用(WeakReference)等方式来管理对象引用,确保对象在不再使用时能够被垃圾回收。

总结

Java内存的线程安全问题是多线程编程中一个至关重要的方面。通过深入理解Java内存模型,包括主内存与工作内存的交互、内存间交互操作等,我们能够明白线程安全问题产生的本质,如竞态条件和可见性问题。

为了解决这些问题,Java提供了多种机制,如 synchronized 关键字、volatile 关键字和原子类等。在实际应用中,我们需要根据具体场景选择合适的同步机制,同时要考虑性能因素。例如,在锁竞争激烈的场景下,synchronized 可能会成为性能瓶颈,此时可以考虑使用原子类或 volatile 来提高性能。

此外,在多线程环境下,还需要注意内存泄漏问题,特别是与线程局部变量和线程池相关的内存泄漏。通过正确使用 ThreadLocal,在任务完成后及时释放资源等方式,可以避免内存泄漏的发生。

通过对Java内存线程安全问题的全面理解和掌握,我们能够编写出更加健壮、高效的多线程Java程序。