ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java线程安全深度解析:从原子性到并发容器实战指南

Java线程安全深度解析:从原子性到并发容器实战指南 1. 先搞明白线程安全到底在说什么如果你刚接触Java并发编程翻论坛、刷面试题会看到一堆和“线程安全”有关的高频词synchronized、volatile、AtomicInteger、ConcurrentHashMap……看得越多越容易晕。我先说一个最基本的判断标准你拿这个标准去套任何一个类、任何一段代码立刻就能知道它到底安全不安全当多个线程同时访问某个类、某个对象或某段代码时如果不需要额外加锁、不需要人为协调程序运行结果始终和单线程执行的结果一致那它就是线程安全的反之只要结果可能出错、数据可能被写坏那就是线程不安全的。说白了线程安全不是一个“有”或“无”的绝对概念而是一个“在什么条件下才能保证正确性”的问题。比如ArrayList和HashMap在单线程下用得很爽但多线程同时读写就会出问题它们就不是线程安全的。而String、Integer这些对象因为本身不可变天然就线程安全。Vector和Hashtable呢每个方法都加了synchronized在多线程环境下调用方法本身是安全的但你要是“先判断再操作”这种复合逻辑依然可能翻车。这就是为什么面试官总喜欢问“Vector是线程安全的为什么我们不用它”——因为方法级别加锁解决不了组合操作的原子性问题还白白牺牲性能。这篇文章我会从现象、原理、解决方案、面试场景四个层面把线程安全这件事讲透。前面两个章节会偏底层原理后面会给出大量可以直接操作的代码示例和实测现象适合正在学Java并发、准备面试、或者工作里被并发问题坑过的同学。2. 用一段代码看清“线程不安全”长什么样2.1 复现现场两个线程同时改一个数字你先别急着看理论我建议你在本地把下面这段代码跑一遍亲眼看一看线程不安全是怎么发生的。这段代码是几乎所有Java并发教程里的经典例子多个线程同时对同一个int变量做自增操作。public class UnsafeDemo { private static int count 0; public static void main(String[] args) throws InterruptedException { int threadCount 10; int incrementPerThread 10000; Thread[] threads new Thread[threadCount]; for (int i 0; i threadCount; i) { threads[i] new Thread(() - { for (int j 0; j incrementPerThread; j) { count; } }); threads[i].start(); } for (Thread thread : threads) { thread.join(); } System.out.println(最终结果: count); System.out.println(期望结果: threadCount * incrementPerThread); } }一共10个线程每个线程对count自增1万次理论上最终应该是10万。但你把这段代码多跑几次结果大概率不是10万而是八九万甚至更少。我本地实测的几次结果分别是94146、96283、91877没有一次能到100000。2.2 为什么count不是原子操作很多人第一次看到这个结果会懵count不就是一行代码吗难道一行代码还能被拆开这就是问题所在。你写的是Java高级语言代码但CPU执行的是编译后的字节码指令。count在字节码层面会被拆成至少三步操作从主内存读取count的当前值到工作内存在工作内存中把值加1把新的值写回主内存。这三步之间是可以被线程切换打断的。假设count当前是5线程A读到5然后CPU时间片用完了切到线程B。线程B也读到5加1变成6写回主内存。接着线程A继续执行它手里还握着刚才读到的5加1也变成6再写回。两个线程各自加了一次但count只从5变成了6——有1次自增白白丢了。这就是经典的“丢失更新”问题。你可能会想那我给count加上volatile不就行了不行volatile能保证可见性能保证各个线程之间读到的是最新值但它不能保证复合操作的原子性。多个线程同时进行“读-改-写”volatile从左到右只能保证读的时候是最新值但在读和写之间的那个间隙竞争依然存在。这个问题我后面专门开一节讲。3. 线程不安全的三宗罪原子性、可见性、有序性3.1 原子性一步到位不可拆散原子性的意思是一个操作要么全部执行成功要么完全不执行中间不能被线程调度机制打断。你可以把它类比成转账你给朋友转100块钱账户扣款和对方到账必须同时完成不能出现你这边扣了钱、对方却没收到的情况。在Java里对基本类型的赋值操作比如int x 1是原子性的不会被打断。但前面说的count不是原子操作因为它包含“读-改-写”三步。解决原子性问题最直接的方式就是给代码块加锁也就是后面要讲的synchronized和Lock让一段代码在同一时刻只能被一个线程执行。还有一种方式是使用原子类比如AtomicInteger它通过CASCompare And Swap机制在硬件层面保证操作的原子性。3.2 可见性你改的值别人看不到可见性问题来自Java内存模型JMM。JMM规定每个线程都有自己的工作内存线程对变量的所有操作都发生在线程自己的工作内存中不能直接读写主内存中的变量。工作内存和主内存之间通过特定的交互协议同步数据。说的直白一点线程A改了变量的值这个新值可能还停留在A的工作内存里没有刷新到主内存这时候线程B读取到的就还是旧值。为了解决可见性问题Java提供了volatile关键字。被volatile修饰的变量每次被修改后都会立即刷新到主内存每次被读取时也强制从主内存读取最新值。它像一个公告板任何人改了内容所有人都能看到最新的公告。这解决了“看不看得见”的问题但解决不了“一起改会不会乱”的问题所以volatile不能替代锁。3.3 有序性编译器不会按你写代码的顺序执行有序性说的是代码执行的顺序。你可能会觉得编译器一定按照你写的代码顺序一行一行执行但实际上为了优化性能编译器和CPU可能会对指令进行重排序。在单线程环境下重排序不会改变程序的执行结果但在多线程环境下指令重排可能导致意料之外的结果。一个经典的例子是双重检查锁单例模式。如果不用volatile修饰单例变量在多线程环境下一个线程可能看到一个“部分构造完成”的对象因为这个对象的引用赋值操作可能被重排序到构造方法真正执行完毕之前。所以Java设计者专门在JMM里规定了happens-before规则用来约束指令重排的边界。synchronized和volatile都遵守这些规则Lock内部也处理了有序性。3.4 并发编程三特性对照表特性含义不满足时的后果典型解决手段原子性操作不可拆分不可中断丢失更新数据写错synchronized、Lock、AtomicInteger可见性一个线程的修改对其他线程可见读到过期数据volatile、synchronized、Lock有序性代码按预期顺序执行对象初始化未完成就被使用volatile、happens-before规则这三大特性不是三件独立的事而是互相纠缠的。一个线程安全的类往往是在这三个维度上都做了正确的约束。比如你后面看到ConcurrentHashMap它复杂的点就在于写操作要保证原子性扩容过程要保证其他线程能看到最新状态遍历时又要保证弱一致性能接受才换来了超高的并发性能。4. 让线程变安全的主流方案与底层原理4.1 一把大锁走天下synchronizedsynchronized是Java内置的关键字是“悲观锁”的代表。它的思路非常朴素同一时刻只允许一个线程进入临界区其他线程必须等待。加在普通方法上锁的是当前实例对象this加在静态方法上锁的是Class对象加在代码块上锁的是你指定的那个对象。public class SyncDemo { private int count 0; public synchronized void increment() { count; } public void incrementWithBlock() { synchronized (this) { count; } } }synchronized在JDK 6之后做了大量锁升级优化偏向锁、轻量级锁、重量级锁。没有竞争时偏向锁能让加锁几乎无成本有轻微竞争时升级为轻量级锁通过CAS自旋抢锁竞争激烈时升级为重量级锁内核参与线程阻塞和唤醒。所以现在的人说“synchronized性能不差”是有道理的但要注意极端高并发场景下锁竞争激烈时线程阻塞唤醒的开销依然很可观。4.2 更灵活的锁Lock接口与ReentrantLockLock接口是JDK 5之后提供的并发工具ReentrantLock是它的典型实现。相比synchronizedLock有更明显的优势支持公平锁和非公平锁、支持可中断的锁获取、支持超时等待、支持多个条件变量。使用上需要自己手动加锁和解锁记住一定要在finally里释放锁否则出异常容易死锁。public class LockDemo { private final ReentrantLock lock new ReentrantLock(); private int count 0; public void increment() { lock.lock(); try { count; } finally { lock.unlock(); } } }你可能想问有synchronized了为什么还要Lock最实际的一个场景你需要尝试获取锁获取不到在规定时间内就放弃synchronized做不到这个——它只能死等但Lock的tryLock(3, TimeUnit.SECONDS)可以。另一个场景读多写少的情况下ReentrantReadWriteLock可以允许多个线程同时读只在写入时互斥这样能极大提高读操作的并发度。4.3 无锁方案volatile和CASvolatile我前面说过它解决可见性和有序性问题但解决不了原子性。所以它适合修饰那些“一个线程写、多个线程读”的状态标志位。比如常见的关闭开关public class ShutdownDemo { private volatile boolean running true; public void stop() { running false; } public void work() { while (running) { // do something } } }CAS则是比较并交换它是AtomicInteger等原子类的底层实现。你可以把CAS理解成一个更底层的操作先读取当前值如果这个值和预期值相等说明中间没有人改过就把新值写进去如果不等说明有人改了就重新读值再试。这个过程是CPU指令级别的原子操作比加锁开销小得多。import java.util.concurrent.atomic.AtomicInteger; public class AtomicDemo { private static final AtomicInteger count new AtomicInteger(0); public static void main(String[] args) throws InterruptedException { int threadCount 10; Thread[] threads new Thread[threadCount]; for (int i 0; i threadCount; i) { threads[i] new Thread(() - { for (int j 0; j 10000; j) { count.incrementAndGet(); } }); threads[i].start(); } for (Thread thread : threads) { thread.join(); } System.out.println(count.get()); } }这段代码用AtomicInteger执行多少次结果都是100000。原子类的适用场景非常明确单变量计数器、累加器、序列号生成器。但也不要以为原子类就万能了它解决不了“多个变量之间的复合操作”——比如一个账户的余额不能为负这种“先检查再操作”的组合逻辑原子类单靠一个变量是无能为力的。4.4 线程安全的容器CopyOnWriteArrayList和ConcurrentHashMap容器的线程安全也有专门的工具。CopyOnWriteArrayList适用于读多写少且写操作频率很低的场景它的读写分离思路是读的时候不加锁写的时候复制一份新的数组改完再替换引用读的永远是快照所以迭代过程中不会出现ConcurrentModificationException。代价是每次写都要复制整个数组写成本很高。ConcurrentHashMap则是面试里最常问的容器。JDK 7的ConcurrentHashMap用分段锁把数据分成多个Segment每个Segment独立加锁提高并发度。JDK 8之后改为CAS配合synchronized锁粒度细化到了单个桶链表头节点或红黑树根节点并发度更高。你可以直接把它当HashMap用在多线程下不需要额外做同步大部分场景下性能都优于给整个HashMap加锁。如果你当前还在用Hashtable强烈建议换成ConcurrentHashMapHashtable每个方法都加锁并发读写时几乎等于串行执行性能差了好几个量级。4.5 方案选型的核心判断逻辑选哪种方案很多时候不是看谁高级而是看你的实际场景和成本。我通常按这个思路快速判断如果只是一个标志位或状态开关用volatile成本最低如果是一个数字需要频繁自增、累加用AtomicInteger或LongAdder避免线程阻塞如果是一段代码逻辑需要互斥执行synchronized简单够用如果需要超时控制、可中断锁、公平锁或者读写锁分离用ReentrantLock或ReentrantReadWriteLock如果是集合容器优先用java.util.concurrent包里的线程安全版本。5. 面试高频题AtomicInteger线程安全吗synchronized和Lock怎么选5.1 AtomicInteger的线程安全性分析最近AtomicInteger这个词在面试里出现的频率特别高题目往往就是“AtomicInteger线程安全吗为什么安全”答案是它是线程安全的因为它内部通过CAS操作保证了并发场景下的原子性。自增操作incrementAndGet实际上是在一个循环里不断尝试CAS直到更新成功为止。在低到中等并发度下AtomicInteger的性能比synchronized好但在极高并发下大量线程同时CAS同一个变量会导致很多线程自旋重试CPU开销会变大。Java还提供了LongAdder它在高并发下采用分段累加的方式把单点竞争分散成多个Cell性能更优但返回值是弱一致性的适合统计场景。所以你回答的时候可以说AtomicInteger线程安全是因为它利用CAS硬同步保证自增这类复合操作的原子性不受线程切换影响。但它的线程安全仅限于“单个变量的单个操作”如果业务需要保证多个变量之间的联合一致性光靠AtomicInteger是不够的。5.2 synchronized和ReentrantLock的区别这道题基本是必考题。我会从几个角度来对比语法层面synchronized是关键字自动加锁、自动解锁出了异常JVM会自动释放锁不会造成死锁ReentrantLock是API必须手动加锁、在finally里手动解锁。功能层面ReentrantLock支持公平锁、可中断锁、超时获取锁synchronized只支持非公平锁也不能中断等待。性能层面JDK 6之后两者在性能上差距很小因为synchronized也有锁升级优化不再是无脑走内核阻塞。使用建议能用synchronized就用synchronized代码更安全更简洁需要超时、中断、公平锁这些高级功能时再换ReentrantLock。5.3 线程安全类就是绝对安全吗面试官经常会追问一句既然说Vector是线程安全的为什么我在遍历它的时候用remove还会报错答案是Vector的方法各自是安全的但不代表“复合操作”安全。比如“先判断集合里有没有元素再决定要不要删除”这一段逻辑两个线程可能同时判断出有元素然后同时执行删除结果一个删成功一个抛异常。这个问题的本质是线程安全需要从操作粒度上去看。单次方法调用安全不等于“组合的调用过程安全”。如果你需要一段复合逻辑在多线程下正确工作需要在逻辑外围再加一把锁而不要指望单个类的线程安全属性替你搞定整个过程。6. 避坑指南与实战排雷6.1 用String当锁对象是大忌我见过不少人在代码里写synchronized(lock)或者synchronized(userName)这是非常危险的。字符串常量在JVM里会被缓存如果你给两个不同用途的代码块用了同一个字符串常量它们会互相锁住造成完全没有必要的阻塞。正确做法是使用专门的private final Object lock new Object()作为锁对象。// 错误示范 public void badMethod() { synchronized (LOCK) { // ... } } // 正确示范 private final Object lock new Object(); public void goodMethod() { synchronized (lock) { // ... } }6.2 加锁范围太小或太大都不对锁加得太小可能遗漏共享变量导致线程安全漏洞锁加得太大比如把整个业务方法包括远程调用全部锁住会导致吞吐量直线下降。我的经验是锁的范围只包裹住真正操作共享数据的代码远程调用、数据库访问、IO等待这些耗时操作尽量放在锁外面否则锁竞争会非常激烈系统性能很容易崩掉。6.3 不要迷信volatilevolatile被很多人误解成“万能的并发关键字”实际上它只是保证可见性和有序性不保证原子性。你如果写count就算加上volatile多线程下照样丢数据。正确用法是前面提到的“一个线程写、其他线程读”的标志位场景。如果拿不准自己该不该用volatile一个简单的判断标准这个变量是不是只被一个线程修改如果不是就别用volatile了。6.4 死锁排查思路加了锁之后最怕遇到死锁。死锁的典型场景是线程A持有锁1等待锁2线程B持有锁2等待锁1两个线程互相等待谁也不让谁。排查死锁最实用的方式是用jstack命令导出线程快照检查哪些线程处于BLOCKED状态以及它们各自等待的锁对象的Monitor。写代码时如果能通过“锁定顺序一致”来避免死锁那就尽量用同一顺序获取多个锁否则可以考虑用tryLock超时机制获取不到锁就回退而不是无限等待。6.5 现场遇到并发问题怎么快速定位我在工作中遇到过几次“偶尔出现一次数据错乱”的问题这类问题最难排查因为它不是每次都发生只在特定时机才暴露。我的排查顺序一般是先看涉及的变量是不是存在多线程读写再确认变量有没有用volatile或加锁保护接着检查是不是复合操作比如“检查之后再更新”最后用压测工具复现把线程数调高、循环次数调大让问题更容易暴露拿到线程快照后分析具体阻塞点。这类问题如果能在设计阶段就养成一个习惯——所有共享可变变量集中管理用明确的并发策略保护后面能少很多查问题的夜晚。写在最后身边总有朋友问我能不能用一两句话解释清楚线程安全我经常这样说如果一个变量或者一个对象同时被多个线程使用你不会因为它被切走了CPU时间片就担心它坏掉那它就是线程安全的。如果有人说“这段代码是安全的”你不妨反问一句你它是怎么处理原子性、可见性、有序性的答得上来的才是真安全答不上来的多半是靠运气。我自己踩过最深的坑就是刚学并发时觉得“用了synchronized就等于万事大吉”结果在锁里放了远程调用压测时线上接口RT飙到十几秒。后来才明白并发编程不是“会不会加锁”的问题而是“什么时候应该锁、锁多大范围、锁完之后怎么降低竞争”的问题。希望这篇文章能帮你把这些点串起来后面的路会顺畅很多。
返回列表