1. 线程安全与可见性基础概念
在Java并发编程中,线程安全和可见性是两个最核心也最容易出问题的概念。我见过太多项目因为对这两个概念理解不到位,导致线上出现难以复现的诡异bug。先明确下定义:
线程安全指的是当多个线程访问某个共享资源时,无论运行时环境采用何种调度方式,代码都能表现出正确的行为。而可见性则是指一个线程对共享变量的修改,能够及时被其他线程看到。
这两个概念看似简单,但在实际开发中却暗藏玄机。比如下面这个经典案例:
public class VisibilityDemo { private static boolean flag = true; public static void main(String[] args) throws InterruptedException { new Thread(() -> { while (flag) { // 空循环 } System.out.println("Thread stopped"); }).start(); Thread.sleep(1000); flag = false; System.out.println("Main thread set flag to false"); } }你猜这个程序会正常退出吗?实际运行会发现,子线程可能永远无法退出!这就是典型的可见性问题 - 主线程对flag的修改对子线程不可见。
2. 内存模型与happens-before原则
要理解线程安全和可见性,必须深入Java内存模型(JMM)。JMM定义了线程如何以及何时可以看到其他线程写入的共享变量,以及如何同步对这些变量的访问。
关键点在于happens-before原则,它规定了哪些操作必须发生在哪些操作之前。几个重要的happens-before规则:
- 程序顺序规则:同一线程中的每个操作happens-before于该线程中的任意后续操作
- 监视器锁规则:对一个锁的解锁happens-before于随后对这个锁的加锁
- volatile变量规则:对volatile域的写happens-before于任意后续对这个volatile域的读
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C
理解这些规则对编写正确的并发代码至关重要。比如我们来看一个双重检查锁定(DCL)的实现:
public class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }这个看似完美的实现其实是有问题的!因为new Singleton()操作和将引用赋值给instance之间可能存在重排序,导致其他线程看到未完全初始化的对象。正确的做法是将instance声明为volatile。
3. 保证线程安全的几种方式
在实际项目中,我们有多种方式可以保证线程安全,各有适用场景:
3.1 不可变对象
最简单的线程安全方案就是使用不可变对象。如果一个对象创建后其状态不能被修改,那么它天生就是线程安全的。Java中的String、Integer等都是不可变对象的典型例子。
创建不可变对象需要遵循几个原则:
- 所有字段设为final
- 类本身声明为final防止子类修改
- 不提供修改内部状态的方法
- 如果字段是可变对象的引用,需要防御性拷贝
public final class ImmutablePoint { 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; } }3.2 同步代码块
使用synchronized关键字是最直接的线程安全方案。它可以确保同一时刻只有一个线程能执行特定代码块或方法。
public class Counter { private int count; public synchronized void increment() { count++; } public synchronized int getCount() { return count; } }但同步会带来性能开销,过度使用会导致程序吞吐量下降。我建议只在必要时使用同步,并且尽量减小同步块的范围。
3.3 volatile关键字
volatile比synchronized更轻量,它能保证变量的可见性但不保证原子性。适合用于状态标志等简单场景:
public class TaskRunner { private volatile boolean running = true; public void stop() { running = false; } public void run() { while (running) { // 执行任务 } } }注意volatile不能用于需要原子性操作的场景,比如i++这种复合操作。
3.4 原子类
Java并发包提供了一系列原子类(AtomicInteger等),它们利用CAS(Compare-And-Swap)指令实现了无锁线程安全:
public class AtomicCounter { private AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } }原子类在高并发场景下性能通常优于同步方案,因为它们避免了线程阻塞。
3.5 并发容器
Java集合框架提供了线程安全的并发容器实现,如ConcurrentHashMap、CopyOnWriteArrayList等。这些容器内部已经处理好了线程安全问题,可以直接在多线程环境中使用。
public class Cache { private ConcurrentMap<String, Object> store = new ConcurrentHashMap<>(); public void put(String key, Object value) { store.put(key, value); } public Object get(String key) { return store.get(key); } }4. 实战案例分析
让我们通过一个电商系统中的库存扣减案例,看看如何选择合适的线程安全方案。
4.1 问题描述
电商系统中,多个用户可能同时购买同一商品,需要确保:
- 库存不会超卖
- 扣减操作高效
- 系统可扩展
4.2 方案选型
方案一:数据库悲观锁
public boolean deductStock(Long productId, int quantity) { // SELECT ... FOR UPDATE // 检查库存 // 扣减库存 // UPDATE ... }优点:实现简单 缺点:性能差,扩展性低
方案二:应用层同步
public synchronized boolean deductStock(Long productId, int quantity) { // 检查库存 // 扣减库存 }优点:实现简单 缺点:性能差,成为系统瓶颈
方案三:乐观锁+重试
public boolean deductStock(Long productId, int quantity) { int retry = 3; while (retry-- > 0) { Product product = getProduct(productId); if (product.getStock() < quantity) { return false; } int oldVersion = product.getVersion(); if (updateStock(productId, quantity, oldVersion) > 0) { return true; } } return false; }优点:性能好 缺点:实现复杂
方案四:Redis原子操作
public boolean deductStock(Long productId, int quantity) { String script = "if redis.call('get', KEYS[1]) >= ARGV[1] then " + "return redis.call('decrby', KEYS[1], ARGV[1]) " + "else return -1 end"; Long result = redisTemplate.execute( new DefaultRedisScript<>(script, Long.class), Collections.singletonList("stock:" + productId), String.valueOf(quantity)); return result != null && result >= 0; }优点:性能极佳 缺点:需要维护缓存一致性
4.3 性能对比
在100并发下测试结果:
| 方案 | QPS | 平均响应时间 | 备注 |
|---|---|---|---|
| 悲观锁 | 120 | 830ms | 数据库成瓶颈 |
| 同步块 | 85 | 1170ms | 单节点性能差 |
| 乐观锁 | 2100 | 47ms | 需要重试机制 |
| Redis | 4500 | 22ms | 最佳性能 |
4.4 最终选择
根据业务场景选择:
- 中小型系统:乐观锁方案
- 大型高并发系统:Redis方案+异步同步数据库
- 超高并发:分布式锁+Redis方案
5. 常见问题与排查技巧
5.1 死锁问题
死锁是并发编程中最棘手的问题之一。典型症状是程序卡死,CPU使用率低。排查步骤:
- 使用jstack获取线程dump
- 查找BLOCKED状态的线程
- 分析锁的持有和等待关系
预防死锁的建议:
- 按固定顺序获取锁
- 使用tryLock设置超时
- 避免在持有锁时调用外部方法
5.2 竞态条件
竞态条件通常表现为偶尔出现的错误,难以复现。典型例子是检查再行动(check-then-act)模式:
if (!map.containsKey(key)) { map.put(key, value); }解决方案:
- 使用线程安全容器
- 使用原子操作
- 使用同步块
5.3 内存一致性错误
这类错误表现为线程看到的数据不一致。常见原因:
- 未正确使用volatile
- 未遵循happens-before原则
- 发布未完全构造的对象
排查工具:
- Java内存模型验证工具
- 静态分析工具FindBugs
5.4 性能问题
过度同步会导致性能下降。优化建议:
- 减小同步范围
- 使用读写锁替代独占锁
- 考虑无锁数据结构
- 使用并发容器
性能分析工具:
- JProfiler
- VisualVM
- Java Mission Control
6. 高级主题与最佳实践
6.1 线程封闭
避免同步的一种有效方式是不共享数据。线程封闭技术包括:
- 栈封闭:局部变量
- ThreadLocal:线程特有变量
- 对象封闭:特定线程独占对象
public class RequestProcessor { private static ThreadLocal<SimpleDateFormat> dateFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd")); public void process(Request request) { // 每个线程有自己的dateFormat实例 String date = dateFormat.get().format(new Date()); // ... } }6.2 不变性与函数式编程
函数式编程强调不可变性,这天然适合并发环境。Java 8引入的流式API和lambda表达式使得编写并发代码更加容易:
public List<Product> getHotProducts(List<Long> productIds) { return productIds.parallelStream() .map(this::getProductInfo) .filter(p -> p.getSales() > 1000) .sorted(comparing(Product::getSales).reversed()) .collect(Collectors.toList()); }6.3 并发设计模式
一些有用的并发设计模式:
- 生产者-消费者模式:BlockingQueue实现
- 工作窃取模式:ForkJoinPool实现
- 领导者-追随者模式:处理高并发IO
// 生产者-消费者示例 public class LogProcessor { private final BlockingQueue<LogEntry> queue = new LinkedBlockingQueue<>(); public void start() { // 生产者线程 new Thread(() -> { while (true) { LogEntry entry = getNextLogEntry(); queue.put(entry); } }).start(); // 消费者线程 for (int i = 0; i < 4; i++) { new Thread(() -> { while (true) { LogEntry entry = queue.take(); processLogEntry(entry); } }).start(); } } }6.4 性能优化技巧
- 避免锁的粗化:不要将不相关的操作放在同一个同步块中
- 减小锁粒度:如ConcurrentHashMap的分段锁
- 读写分离:使用ReadWriteLock
- 无锁算法:如CAS操作
- 避免热点:如AtomicLong在高并发下的性能问题,可以使用LongAdder
// 使用LongAdder替代AtomicLong public class ClickCounter { private final LongAdder count = new LongAdder(); public void click() { count.increment(); } public long getCount() { return count.sum(); } }7. 工具与框架推荐
7.1 监控工具
- JConsole:基本JVM监控
- VisualVM:功能更强大的分析工具
- Java Mission Control:商业级监控
- Arthas:阿里开源的Java诊断工具
7.2 测试工具
- JMH:Java微基准测试工具
- JUnit:单元测试框架
- TestNG:更强大的测试框架
- Mockito:模拟对象框架
7.3 并发框架
- Fork/Join框架:适合计算密集型任务
- Executor框架:线程池管理
- CompletableFuture:异步编程
- RxJava:响应式编程
// CompletableFuture示例 public CompletableFuture<String> getUserInfoAsync(String userId) { return CompletableFuture.supplyAsync(() -> getUserBasicInfo(userId)) .thenCombine(getUserCreditAsync(userId), (basic, credit) -> basic + ", Credit: " + credit); }8. 实际项目经验分享
在多年的Java并发编程实践中,我总结了以下几点经验:
- 优先考虑无共享架构:能用线程封闭解决的问题就不要用同步
- 尽量使用高层抽象:如并发容器、Executor框架等,而不是自己实现
- 避免过早优化:先保证正确性,再考虑性能
- 充分测试并发场景:包括压力测试和长时间运行测试
- 记录并发控制决策:在代码注释中说明为什么选择特定的并发控制方式
一个典型的错误案例:曾经在项目中使用了双重检查锁定来实现单例,但没有使用volatile,导致在特定硬件环境下出现难以复现的问题。后来通过以下方式修复:
public class SafeSingleton { private static volatile SafeSingleton instance; public static SafeSingleton getInstance() { if (instance == null) { synchronized (SafeSingleton.class) { if (instance == null) { instance = new SafeSingleton(); } } } return instance; } }另一个经验是关于锁粒度的选择。在一个高并发的交易系统中,最初我们使用了一个全局锁来保护所有交易操作,导致性能瓶颈。后来改为按用户ID哈希分组锁,性能提升了8倍:
public class TradeService { private final Object[] locks = new Object[16]; { for (int i = 0; i < locks.length; i++) { locks[i] = new Object(); } } public void trade(long userId, TradeAction action) { int lockIndex = (int) (userId % locks.length); synchronized (locks[lockIndex]) { // 执行交易操作 } } }最后,关于可见性问题的一个实际案例:在分布式配置中心项目中,配置变更后需要立即对所有线程可见。最初我们使用了普通的变量加同步块,后来发现某些情况下配置更新会有延迟。最终解决方案是使用volatile结合CopyOnWriteArrayList:
public class ConfigCenter { private volatile Config currentConfig; private final CopyOnWriteArrayList<ConfigListener> listeners = new CopyOnWriteArrayList<>(); public void updateConfig(Config newConfig) { this.currentConfig = newConfig; for (ConfigListener listener : listeners) { listener.onConfigChanged(newConfig); } } public Config getCurrentConfig() { return currentConfig; } }