Java线程安全与同步机制详解
在上一篇文章中,我们介绍了 Java
线程的基础概念。本文将深入探讨 Java
中的线程安全问题以及同步机制,包括线程安全的本质、共享资源与竞态条件、synchronized
关键字、volatile
关键字以及它们的使用。这些知识是进行并发编程的核心,对于编写健壮的多线程应用至关重要。
1. 线程安全问题的本质
1.1 什么是线程安全?
线程安全是指在多线程环境下,代码能够正确地处理多个线程对共享资源的访问,而不会导致数据破坏或者逻辑错误。确切地说,当多个线程访问某个对象时,不管运行时环境采用何种调度方式或者这些线程如何交替执行,这个对象都能表现出正确的行为。
1.2 线程安全问题的三大特性
线程安全问题本质上是由于多线程并发访问共享资源时,可能违反了并发编程的三大特性:
-
原子性(Atomicity)
- 定义:一个操作或者多个操作要么全部执行完成且不会被中断,要么都不执行
- 问题:看似不可分割的操作实际上可能由多个底层操作组成,线程切换可能发生在这些底层操作之间
- 示例:自增操作
count++
实际包含"读取-修改-写入"三个步骤
-
可见性(Visibility)
- 定义:当一个线程修改了共享变量的值,其他线程能够立即看到这个修改
- 问题:由于
CPU
缓存和指令重排,一个线程对共享变量的修改可能不会立即被其他线程看到 - 示例:一个线程修改了退出标志,但其他线程可能仍看到的是缓存中的旧值
-
有序性(Ordering)
- 定义:程序执行的顺序按照代码的先后顺序执行
- 问题:编译器和
CPU
可能会对指令进行重排序以提高性能,导致实际执行顺序与代码顺序不一致 - 示例:双重检查锁定(
DCL
)单例模式中可能出现的部分初始化问题
1.3 JMM(Java内存模型)与线程安全
Java
内存模型(JMM
)是一种规范,定义了Java
虚拟机如何与计算机内存交互。JMM
的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
JMM
规定:
- 所有变量都存储在主内存中
- 每个线程有自己的工作内存,保存了被该线程使用的变量的主内存副本
- 线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量
- 不同线程之间无法直接访问对方工作内存中的变量,线程间变量的传递均需要通过主内存来完成
这种内存模型是导致可见性问题的根源,也是我们需要使用同步机制的原因。
2. 共享资源与竞态条件分析
2.1 共享资源
在并发编程中,共享资源是指可以被多个线程同时访问的资源。常见的共享资源包括:
- 实例或静态变量:类的成员变量可以被多个线程访问
- 集合和数组:如
ArrayList
、HashMap
等 - 数据库连接:多个线程可能同时使用同一个连接
- 文件系统:多个线程可能同时读写同一个文件
- 静态资源和单例对象:静态资源或单例模式创建的对象常常被多线程共享
2.2 竞态条件(Race Condition)
竞态条件是指多个线程以非协调的方式对共享资源进行操作,导致资源状态不一致的现象。最典型的竞态条件是"读-改-写"问题,即线程在读取一个值后,基于该值进行计算,然后将新值写回。
以下是一个经典的计数器问题示例:
public class UnsafeCounter {
private int count = 0;
public void increment() {
count++; // 非原子操作,包含:读取-计算-写入
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
final UnsafeCounter counter = new UnsafeCounter();
// 创建1000个线程,每个线程将计数器加1
Thread[] threads = new Thread[1000];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
counter.increment();
});
threads[i].start();
}
// 等待所有线程执行完毕
for (Thread thread : threads) {
thread.join();
}
// 理论上结果应该是1000
System.out.println("最终计数: " + counter.getCount());
}
}
运行上面的程序,你会发现最终结果很可能小于1000
。这是因为count++
操作不是原子的,它包含三个步骤:
- 读取当前
count
值 - 将值加
1
- 写回新值
如果两个线程同时读取到count=100
,各自加1
后写回101
,最终结果就会丢失一次递增。
2.3 竞态条件案例分析
让我们详细分析一下线程不安全的银行账户转账问题:
public class UnsafeBankAccount {
private int balance;
public UnsafeBankAccount(int initialBalance) {
this.balance = initialBalance;
}
public void withdraw(int amount) {
if (balance >= amount) {
// 模拟网络延迟或其他耗时操作
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
balance -= amount;
}
}
public int getBalance() {
return balance;
}
public static void main(String[] args) throws InterruptedException {
final UnsafeBankAccount account = new UnsafeBankAccount(1000);
// 创建100个取款线程,每个线程取10元
Thread[] threads = new Thread[100];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
account.withdraw(10);
});
threads[i].start();
}
// 等待所有线程执行完毕
for (Thread thread : threads) {
thread.join();
}
// 理论上应该剩余0元
System.out.println("账户余额: " + account.getBalance());
}
}
这段代码可能导致余额为负,因为多个线程可能同时通过余额检查(balance >= amount
),然后各自执行取款操作。这就是典型的"检查后执行"(Check-Then-Act
)竞态条件。
3. synchronized关键字详解
3.1 synchronized的基本用法
Java
提供了synchronized
关键字来实现线程间的互斥同步,它可以保证同一时刻只有一个线程执行被synchronized
修饰的代码块或方法,从而保证线程安全。
synchronized
有三种使用方式:
-
同步实例方法:锁定当前对象实例
public synchronized void method() { // 同步代码 }
-
同步静态方法:锁定当前类的Class对象
public static synchronized void method() { // 同步代码 }
-
同步代码块:锁定指定对象
public void method() { synchronized(this) { // 可以是this、类.class或任意对象 // 同步代码 } }
3.2 对象锁与类锁
synchronized
使用的锁分为对象锁和类锁:
-
对象锁:锁定实例对象,多个线程访问不同对象时不会互斥
- 同步实例方法:
synchronized void method()
- 同步代码块(this):
synchronized(this)
- 同步实例方法:
-
类锁:锁定类,即使多个线程访问不同对象实例也会互斥
- 同步静态方法:
synchronized static void method()
- 同步代码块(类.class):
synchronized(MyClass.class)
- 同步静态方法:
示例代码:
public class SynchronizedDemo {
// 对象锁示例
public synchronized void methodA() {
System.out.println("Method A start - " + Thread.currentThread().getName());
try {
Thread.sleep(2000); // 模拟耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method A end - " + Thread.currentThread().getName());
}
public void methodB() {
synchronized(this) {
System.out.println("Method B start - " + Thread.currentThread().getName());
try {
Thread.sleep(2000); // 模拟耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method B end - " + Thread.currentThread().getName());
}
}
// 类锁示例
public static synchronized void methodC() {
System.out.println("Method C start - " + Thread.currentThread().getName());
try {
Thread.sleep(2000); // 模拟耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method C end - " + Thread.currentThread().getName());
}
public void methodD() {
synchronized(SynchronizedDemo.class) {
System.out.println("Method D start - " + Thread.currentThread().getName());
try {
Thread.sleep(2000); // 模拟耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method D end - " + Thread.currentThread().getName());
}
}
public static void main(String[] args) {
SynchronizedDemo demo1 = new SynchronizedDemo();
SynchronizedDemo demo2 = new SynchronizedDemo();
// 测试对象锁:同一实例的methodA和methodB互斥
new Thread(() -> demo1.methodA(), "Thread1").start();
new Thread(() -> demo1.methodB(), "Thread2").start();
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 测试对象锁:不同实例的methodA不互斥
new Thread(() -> demo1.methodA(), "Thread3").start();
new Thread(() -> demo2.methodA(), "Thread4").start();
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 测试类锁:不同实例的methodC和methodD互斥
new Thread(() -> demo1.methodC(), "Thread5").start();
new Thread(() -> demo2.methodD(), "Thread6").start();
}
}
3.3 synchronized的底层实现
synchronized
的底层实现是基于进入和退出管程(Monitor
)来实现的。在JVM中,对象头中的Mark Word
会存储锁的相关信息。synchronized
在JDK 6
之后引入了偏向锁、轻量级锁和重量级锁的概念,这些是锁的状态升级过程:
- 无锁状态:对象未被锁定
- 偏向锁:只有一个线程访问同步块时,避免锁竞争
- 轻量级锁:多线程竞争不激烈时使用自旋等待,避免线程切换
- 重量级锁:竞争激烈时,线程会被阻塞挂起
这种锁状态的升级过程叫做锁膨胀,目的是根据实际情况使用适合的锁机制,提高性能。
3.4 使用synchronized解决线程安全问题
让我们用synchronized重写之前的不安全计数器和银行账户示例:
public class SafeCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
final SafeCounter counter = new SafeCounter();
Thread[] threads = new Thread[1000];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
counter.increment();
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("最终计数: " + counter.getCount()); // 始终输出1000
}
}
public class SafeBankAccount {
private int balance;
public SafeBankAccount(int initialBalance) {
this.balance = initialBalance;
}
public synchronized void withdraw(int amount) {
if (balance >= amount) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
balance -= amount;
}
}
public synchronized int getBalance() {
return balance;
}
public static void main(String[] args) throws InterruptedException {
final SafeBankAccount account = new SafeBankAccount(1000);
Thread[] threads = new Thread[100];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
account.withdraw(10);
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("账户余额: " + account.getBalance()); // 始终为0
}
}
3.5 synchronized的优缺点
优点:
- 语法简单,使用方便
- 由
JVM
实现,可靠性高 JDK 6
后性能大幅提升- 可重入锁,避免死锁
缺点:
- 无法中断一个正在等待获取锁的线程
- 无法设置超时时间
- 同步块不能异常时自动释放锁
- 使用不当会导致性能问题
4. volatile关键字与内存可见性、有序性
4.1 volatile关键字的作用
volatile
关键字是Java
提供的一种轻量级同步机制,它主要有两个作用:
- 保证可见性:当一个线程修改了被
volatile
修饰的变量时,该变量的新值对其他线程立即可见 - 禁止指令重排序:
volatile
变量的操作前后的指令不会被重排序
需要注意的是,volatile
不能保证操作的原子性。
4.2 内存可见性问题示例
下面是一个没有使用volatile
导致内存可见性问题的例子:
public class VisibilityProblem {
private boolean flag = false;
public void setFlag() {
flag = true;
System.out.println("Flag set to true");
}
public void doWork() {
while (!flag) {
// 执行其他操作
}
System.out.println("Work done!");
}
public static void main(String[] args) throws InterruptedException {
VisibilityProblem example = new VisibilityProblem();
// 工作线程
Thread workerThread = new Thread(() -> {
example.doWork();
});
workerThread.start();
// 主线程暂停一段时间
Thread.sleep(1000);
// 更新标志
example.setFlag();
// 等待工作线程结束
workerThread.join(3000);
// 检查工作线程是否仍在运行
if (workerThread.isAlive()) {
System.out.println("Worker thread is still running!");
workerThread.interrupt();
}
}
}
在上面的例子中,工作线程可能永远不会看到主线程对flag
变量的修改,因此会一直循环。
4.3 使用volatile解决可见性问题
修改上面的例子,使用volatile关键字来解决可见性问题:
public class VisibilitySolved {
private volatile boolean flag = false;
public void setFlag() {
flag = true;
System.out.println("Flag set to true");
}
public void doWork() {
while (!flag) {
// 执行其他操作
}
System.out.println("Work done!");
}
public static void main(String[] args) throws InterruptedException {
VisibilitySolved example = new VisibilitySolved();
Thread workerThread = new Thread(() -> {
example.doWork();
});
workerThread.start();
Thread.sleep(1000);
example.setFlag();
workerThread.join();
}
}
现在工作线程能够看到主线程对flag
变量的修改,程序能够正常结束。
4.4 volatile与原子性
volatile
不能保证复合操作的原子性。以下是一个使用volatile
但仍然线程不安全的计数器示例:
public class VolatileCounter {
private volatile int count = 0;
public void increment() {
count++; // 虽然count是volatile的,但increment操作不是原子的
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
final VolatileCounter counter = new VolatileCounter();
Thread[] threads = new Thread[1000];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
counter.increment();
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("最终计数: " + counter.getCount()); // 可能小于1000
}
}
这个例子中,尽管使用了volatile关键字,但计数器的结果仍然可能不正确。这是因为count++
不是原子操作,它包含读取、修改和写入三个步骤。
4.5 双重检查锁定与volatile
volatile
的另一个重要用途是在单例模式的双重检查锁定(Double-Checked Locking
)中防止部分初始化问题:
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
这里的volatile
关键字是必要的,因为instance = new Singleton()
操作实际上包含三个步骤:
- 分配内存空间
- 初始化对象
- 将引用指向分配的内存
在没有volatile
的情况下,JVM
可能会对这些步骤进行重排序,导致其他线程看到一个未完全初始化的对象。
5. ThreadLocal详解与使用场景
5.1 ThreadLocal的基本概念
ThreadLocal
提供了线程局部变量,每个线程都可以通过set()
和get()
方法来访问自己独立的变量副本,互不干扰。ThreadLocal
实例通常是类中的私有静态字段。
基本用法:
public class ThreadLocalExample {
// 创建ThreadLocal变量
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
// 主线程设置值
threadLocal.set("Main Thread Value");
System.out.println("主线程获取值: " + threadLocal.get());
// 创建子线程
Thread thread = new Thread(() -> {
// 子线程设置自己的值
threadLocal.set("Child Thread Value");
// 子线程获取值
System.out.println("子线程获取值: " + threadLocal.get());
});
thread.start();
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 主线程再次获取值,验证没有被子线程修改
System.out.println("主线程再次获取值: " + threadLocal.get());
// 清理资源
threadLocal.remove();
}
}
运行结果显示,子线程对ThreadLocal
变量的修改不会影响主线程中的值。
5.2 ThreadLocal的实现原理
ThreadLocal
的实现原理并不复杂:
- 每个Thread对象都有一个名为
threadLocals
的实例变量,它是ThreadLocalMap
类型的 ThreadLocalMap
是一个定制的哈希表,专门用于存储当前线程的ThreadLocal
变量- 在
ThreadLocalMap
中,key
是ThreadLocal
对象的弱引用,value
是线程局部变量的值 - 当调用
ThreadLocal
的get()
方法时,实际上是从当前线程的ThreadLocalMap
中获取对应的值 - 当调用
ThreadLocal
的set()
方法时,实际上是将值存储在当前线程的ThreadLocalMap
中
5.3 ThreadLocal的内存泄漏问题
ThreadLocal
存在内存泄漏的风险。由于 ThreadLocalMap
的 Entry
使用 ThreadLocal
的弱引用作为 key
,如果 ThreadLocal
对象被垃圾回收,Entry
中的 key
会变成 null
,但 value
仍然存在。如果线程长时间存活(如线程池中的线程),这可能导致内存泄漏。
为了避免内存泄漏,我们应该在不需要使用ThreadLocal
变量时显式调用remove()
方法清除对应的Entry
:
try {
threadLocal.set(value);
// 使用ThreadLocal变量
doSomething();
} finally {
// 使用完毕后及时清理
threadLocal.remove();
}
5.4 ThreadLocal的使用场景
ThreadLocal
适用于以下场景:
-
线程隔离的上下文信息:如用户身份、事务
ID
等public class UserContextHolder { private static final ThreadLocal<User> userContext = new ThreadLocal<>(); public static void setUser(User user) { userContext.set(user); } public static User getUser() { return userContext.get(); } public static void clear() { userContext.remove(); } }
-
每个线程独立的数据库连接或会话
public class ConnectionManager { private static final ThreadLocal<Connection> connectionHolder = ThreadLocal.withInitial(() -> { try { return DriverManager.getConnection("jdbc:mysql://localhost:3306/mydatabase", "user", "password"); } catch (SQLException e) { throw new RuntimeException(e); } }); public static Connection getConnection() { return connectionHolder.get(); } public static void closeConnection() { Connection conn = connectionHolder.get(); if (conn != null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } connectionHolder.remove(); } }
-
线程安全的SimpleDateFormat
public class SafeDateFormat { private static final ThreadLocal<SimpleDateFormat> dateFormatHolder = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); public static String format(Date date) { return dateFormatHolder.get().format(date); } public static Date parse(String dateStr) throws ParseException { return dateFormatHolder.get().parse(dateStr); } }
-
避免参数传递:在调用链中传递上下文信息,无需显式传递参数
public class ServiceContext { private static final ThreadLocal<Map<String, Object>> contextHolder = ThreadLocal.withInitial(HashMap::new); public static void set(String key, Object value) { contextHolder.get().put(key, value); } public static Object get(String key) { return contextHolder.get().get(key); } public static void clear() { contextHolder.get().clear(); contextHolder.remove(); } }
5.5 InheritableThreadLocal
InheritableThreadLocal
是ThreadLocal
的子类,它允许子线程访问父线程中设置的值:
public class InheritableThreadLocalExample {
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
private static InheritableThreadLocal<String> inheritableThreadLocal =
new InheritableThreadLocal<>();
public static void main(String[] args) {
threadLocal.set("Parent ThreadLocal Value");
inheritableThreadLocal.set("Parent InheritableThreadLocal Value");
Thread thread = new Thread(() -> {
System.out.println("子线程获取ThreadLocal值: " + threadLocal.get()); // 输出null
System.out.println("子线程获取InheritableThreadLocal值: " + inheritableThreadLocal.get()); // 输出父线程的值
});
thread.start();
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
需要注意的是,InheritableThreadLocal
只有在创建子线程时会传递父线程的值,之后父线程值的修改不会影响已创建的子线程。
总结
本文详细介绍了Java中的线程安全问题及同步机制,包括线程安全的本质、共享资源与竞态条件、synchronized关键字、volatile关键字以及ThreadLocal的使用。
线程安全问题本质上是由于多线程环境下对共享资源的非协调访问导致的,主要涉及原子性、可见性和有序性三个方面。Java提供了多种同步机制来解决这些问题:
- synchronized关键字保证了同步代码块的原子性、可见性和有序性,是解决线程安全问题的基本工具
- volatile关键字保证了变量的可见性和有序性,但不保证原子性,适用于一个线程写多个线程读的场景
- ThreadLocal提供了线程隔离的局部变量,避免了共享资源的竞争,适用于保存线程上下文信息
在实际开发中,我们需要根据具体场景选择合适的同步机制。对于简单的同步需求,synchronized是首选;对于仅需要保证可见性的场景,volatile更轻量;而当需要线程隔离而非共享时,ThreadLocal是理想选择。
在后续文章中,我们将继续探讨更高级的并发控制机制,如Lock接口、并发容器、原子类以及线程池等内容。
希望本文对你理解Java线程安全与同步机制有所帮助!如有问题或建议,欢迎在评论区留言讨论。