拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Java并发编程核心知识总结:从JMM到线程池的面试实战指南

1. 并发编程到底在解决什么问题先搞清楚三个特性再背题很多准备Java面试的朋友一上来就背synchronized和volatile的区别结果面试官换个角度问并发编程的三大特性是什么它们各自解决了什么问题立马卡壳。这种背法其实是最吃亏的因为你只记住了结论没理解结论背后的因果关系。1.1 并发Bug的三大根源缓存、重排与线程切换并发编程之所以难是因为它同时面对三个捣乱分子。第一个是CPU缓存带来的可见性问题。CPU的运算速度远快于内存访问速度所以中间加了多级缓存。单核时代缓存只对当前核心可见不存在问题到了多核时代线程A在核心1上修改了变量x的值这个修改可能还停留在L1缓存里线程B在核心2上读到的x还是旧值。这就叫一个线程修改了共享变量但另一个线程不能立即看到。第二个是编译器指令重排带来的有序性问题。为了充分利用CPU流水线编译器和CPU会对指令进行重排序只要保证单线程语义不变。但多线程环境下这种重排可能导致程序行为完全出乎意料。最经典的就是单例模式的双重检查锁new一个对象在字节码层面分三步分配内存、初始化对象、将引用指向内存。这三步可能被重排成分配内存、引用指向内存、初始化对象另一个线程在这个间隙拿到的引用是非空的但对象还没初始化完用起来就炸了。第三个是线程切换带来的原子性问题。比如count这条语句在字节码层面要经历读取count、加1、写回count三步任何一步都可能被线程切换打断。两个线程同时执行count理论上应该加2但实际可能只加了1。这就是典型的原子性问题一个操作在中间状态被其他线程插入了。1.2 JMM内存模型与happens-before规则Java内存模型Java Memory ModelJMM就是为了规范上面三个问题而制定的一套规则。它规定了所有变量存储在主内存中每个线程有自己的工作内存可以类比CPU缓存线程对变量的操作必须先在主内存中拷贝副本到工作内存操作完再刷回主内存。JMM本身是一个抽象模型它不关心底层是CPU缓存还是内存屏障实现只管两个层面的东西一是定义各种内存操作的执行规则二是规定哪些情况下一个线程的操作对另一个线程可见。这就引出了happens-before规则它是判断数据是否存在竞争的关键依据。核心规则包括程序次序规则一个线程内写在前面的操作先行发生于后面的操作。锁规则解锁操作先行发生于后面时间上对同一把锁的加锁操作。volatile变量规则对volatile变量的写操作先行发生于后面时间上对该变量的读操作。传递性A先行发生于BB先行发生于C则A先行发生于C。线程启动/终止/中断规则线程的start()先行发生于该线程的任何操作线程的所有操作先行发生于其他线程检测到该线程终止对线程的interrupt()调用先行发生于被中断线程检测到中断事件。实际面试的时候如果你能结合一个具体场景来讲happens-before——比如变量用volatile修饰后为什么读线程能看到写线程的最新值——就会比单纯列规则显得扎实得多。1.3 怎么快速判断一个并发问题属于哪一类我在实际排查并发Bug时习惯先给问题分类再针对性处理这比漫无目的地翻代码效率高得多如果表现为某个线程改了值其他线程就是看不到基本是可见性问题优先考虑volatile或加锁。如果表现为明明加了判断但还是进入了不该进入的代码分支多半是重排序问题比如单例双重检查锁没加volatile或者懒加载的缓存对象没做安全发布。如果表现为count结果比预期小、集合里丢数据、偶尔抛并发修改异常大概率是原子性问题需要加锁或者用原子类。面试时被问三大特性你怎么理解能这样把概念落地到具体现象上会让面试官觉得你不仅背过还真的处理过问题。2. volatile和synchronized两个最基础关键字的底层逻辑这两个关键字是Java并发编程的基石也是面试官最喜欢深挖的点。如果你只是背一句volatile保证可见性synchronized保证原子性那这场面试基本就止步于此了真正的分水岭在于你能不能讲清楚它们的底层实现。2.1 volatile可见性与有序性但不保证原子性volatile关键字的底层语义可以拆成两部分来说。可见性靠的是缓存一致性协议和锁前缀指令。在JVM层面对volatile变量执行写操作时JIT编译器会生成带lock前缀的指令。这个lock前缀实际上是一个内存屏障它有两个效果一是将当前处理器缓存行的数据写回系统内存二是这个写回操作会使其他CPU核心里缓存了同一内存地址的数据失效通过缓存一致性协议比如MESI协议实现。这样一来其他线程读这个变量时就不得不重新从主内存拉取看到的自然是最新的值。有序性靠的是内存屏障。JMM在volatile的读写前后插入了内存屏障指令防止指令重排越过volatile变量的边界。具体来说在volatile写操作前面插入StoreStore屏障写操作后面插入StoreLoad屏障在volatile读操作后面插入LoadLoad屏障和LoadStore屏障。这些屏障的效果是volatile写之前的普通写操作不会被重排到写之后volatile读之后的普通读操作也不会被重排到读之前。但volatile有一个硬伤它不保证原子性。这需要理解一个关键点可见性解决的是读到旧值的问题原子性解决的是读改写三步被拆开的问题。比如两个线程同时执行volatileCount它们都可能读到同一个旧值然后分别加1写回最终结果是丢了一次加1。所以在需要递增、累加这类复合操作场景volatile是无能为力的必须靠原子类或锁。2.2 synchronized的锁升级从偏向锁到重量级锁synchronized在JDK 1.6之后经历了一次重大优化。早年它是一把重量级锁直接依赖操作系统的互斥量monitor线程阻塞和唤醒都要切换到内核态性能很差。HotSpot团队后来引入了锁升级机制让锁在大多数场景下都停留在较轻量级的状态。锁的状态演进路线是无锁 → 偏向锁 → 轻量级锁 → 重量级锁。这个升级方向是单向的只升不降。偏向锁的核心思想是在大多数场景下锁不仅不存在多线程竞争而且总是由同一个线程反复获取。于是锁会记录第一个获取它的线程ID后续这个线程再来时只要判断对象头的Mark Word中记录的线程ID是自己就无需任何CAS操作直接进入临界区。只有出现其他线程竞争时偏向锁才撤销升级为轻量级锁。轻量级锁的核心思想是用CAS操作代替互斥量的使用。线程尝试在自己的栈帧中分配一块锁记录空间然后用CAS把对象头中的Mark Word替换为指向锁记录的指针。如果替换成功说明获取锁成功如果失败说明有竞争锁会膨胀为重量级锁。重量级锁就是传统的monitor锁通过操作系统的互斥量实现线程竞争不到锁时会进入阻塞状态涉及到用户态和内核态的切换开销最大。为什么锁要设计成只升不降一方面是因为锁升级的状态信息都保存在对象头的Mark Word里降级逻辑复杂收益有限另一方面一旦出现竞争说明这个锁的并发程度已经上来了后续大概率还会继续竞争保持在重量级反而减少反复横跳的开销。2.3 实战判断哪些场景选volatile哪些必须上synchronized根据我自己的经验volatile的适用场景其实非常窄同时满足以下三个条件时才用它对变量的写操作不依赖于当前值比如状态标志位、开关量。该变量不与其他的状态变量共同参与不变性约束。访问该变量时不需要加锁。典型场景包括boolean类型的停止标志、发布不可变对象对象本身用final修饰通过volatile引用对外发布。除此之外但凡涉及先读后写的复合操作老老实实上synchronized或原子类。synchronized的适用面就广得多所有需要保证原子性的临界区、不可变对象的安全发布、多线程共享的可变状态保护。它的缺点是获取锁和释放锁的成本相对固定不支持超时中断也不支持多个条件变量。但JDK 1.6之后在低竞争场景下它的性能已经和ReentrantLock相差无几而且不需要手动释放锁容错性更好所以我在生产代码里仍倾向于优先用synchronized。3. 锁的进阶从CAS到AQS的一整套体系synchronized代表的是悲观锁的思路先假设冲突一定会发生所以给临界区加锁锁不住就阻塞。而Java并发包JUC里的大量工具类走的是乐观锁的路线先假设冲突不会发生操作时直接执行失败了再重试。这套体系的基石是CAS和AQS。3.1 CAS的底层实现与ABA问题CASCompare And Swap是一个原子指令它包含三个操作数内存位置V、预期原值A、新值B。执行时只有当V的值等于A时才把V更新为B否则不做任何操作。整个比较和交换过程是硬件层面保证原子性的。在JDK的Unsafe类中CAS最终会调用JNI方法底层对应的是CPU的cmpxchg指令。以x86架构为例这条指令会先比较目的操作数的值与累加器值相等则写入源操作数并设置状态标志位。在多核CPU上执行时cmpxchg指令前会加lock前缀锁定总线或缓存行保证操作的原子性。CAS带来的问题是我们常说的ABA问题线程1读取变量值为A准备更新为B在此之前线程2把值从A改为C又改回A线程1执行CAS时发现值还是A比较成功于是执行更新。但事实上这个值已经被其他线程修改过可能已经不再满足业务前提条件。解决ABA问题有两个思路一是用AtomicStampedReference在引用上附加版本号每次修改版本号加1CAS时同时校验引用和版本号二是用AtomicMarkableReference只标记是否被修改过。实际业务中如果变量是基本数值类型且业务上不关心值是否发生过从A改到C又回到A的波动ABA问题通常可以被忽略但如果涉及链表节点复用这类场景就必须用带版本号的方案。3.2 ReentrantLock与synchronized的对比ReentrantLock是JUC里最常用的可重入锁它的关键在于与synchronized的差异点维度synchronizedReentrantLock是否可重入是是锁的获取和释放自动必须手动lock/unlock通常配finally公平性非公平支持公平和非公平模式响应中断不支持支持lockInterruptibly超时获取不支持支持tryLock(timeout, unit)条件变量通过wait/notify实现一个锁只有一个等待队列支持多个Condition可分组唤醒底层实现锁升级 monitorAQS同步器关于公平锁和非公平锁有一个细节值得注意非公平锁的性能优于公平锁。原因在于公平锁要求线程严格按申请顺序获取锁这意味着后来的线程即使锁刚好处于可用状态也必须排队等待导致上下文切换更频繁。非公平锁允许线程插队减少了线程切换成本虽然可能造成个别线程长时间等待但整体吞吐量更高。ReentrantLock默认是非公平的就是这个原因。3.3 AQS核心原理state、CLH队列与模板方法AbstractQueuedSynchronizerAQS是整个JUC锁体系的地基ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock都基于它实现。AQS的核心数据结构有三个volatile int state、CLH变体双向队列、以及资源的获取与释放逻辑。state是同步状态不同的子类对它有不同的解释。在ReentrantLock里state表示锁的重入次数在Semaphore里state表示剩余的许可证数量在CountDownLatch里state表示还需要等待的计数。对state的操作通过CAS完成保证原子性。CLH队列是一个双端队列每个节点包含线程引用、等待状态、前驱和后继指针。当线程获取同步状态失败时会封装成节点加入队列尾部并通过LockSupport.park挂起自己当持有锁的线程释放同步状态后会唤醒队首节点的线程让它重新尝试获取。AQS的设计模式叫模板方法模式父类AQS定义了acquire、release等一系列模板方法把整体骨架固定下来然后把如何获取状态、如何释放状态这些具体步骤通过tryAcquire、tryRelease等钩子方法留给子类实现。比如ReentrantLock的非公平锁实现了tryAcquire直接尝试CAS修改state它和公平锁的tryAcquire只差一个是否有前驱节点在等待的判断。这也就是为什么面试问AQS时你回答说它用了一个volatile的state和一个FIFO队列通过模板方法模式暴露钩子让子类实现就能得到不错的评价。3.4 死锁的产生与排查经验死锁是最容易在并发编程中出现、也最容易在面试中被追问的问题。它的产生必须同时满足四个必要条件互斥、持有并等待、不可剥夺、循环等待。破坏其中任何一个死锁就不会发生。我举一个典型的死锁场景public class DeadlockDemo { private static final Object lockA new Object(); private static final Object lockB new Object(); public static void main(String[] args) { Thread t1 new Thread(() - { synchronized (lockA) { System.out.println(线程1持有锁A); try { Thread.sleep(100); } catch (InterruptedException ignored) {} synchronized (lockB) { System.out.println(线程1获取锁B); } } }); Thread t2 new Thread(() - { synchronized (lockB) { System.out.println(线程2持有锁B); try { Thread.sleep(100); } catch (InterruptedException ignored) {} synchronized (lockA) { System.out.println(线程2获取锁A); } } }); t1.start(); t2.start(); } }运行后线程1持有锁A等待锁B线程2持有锁B等待锁A互相等待谁也进不去。排查死锁最直接的手段是生成线程转储thread dump。用jps找到Java进程ID再执行jstack pid如果存在死锁输出末尾会出现Found one Java-level deadlock的提示并列出循环等待的锁信息。在JDK 9以上也可以直接用jcmd pid Thread.print。预防死锁的经验我总结下来有三条一是控制加锁顺序所有线程按相同的顺序获取多个锁从机制上消除循环等待二是使用超时锁用ReentrantLock的tryLock设定获取锁的超时时间拿不到就释放已持有的锁并重试三是尽量缩小锁的粒度不要在持锁状态执行耗时操作比如远程调用、IO读写既降低死锁概率也提升并发度。4. 线程池面试必考的参数、流程与拒绝策略线程池是Java并发面试的标配题目基本每场必问。问的形式通常是从ThreadPoolExecutor构造函数的七个参数是什么开始然后一路追到任务提交的内部流程、拒绝策略的选择、以及生产环境怎么配置。这块如果只背参数和流程面试官稍微往深处一问就会露馅。4.1 核心参数逐个拆解ThreadPoolExecutor的构造函数有七个核心参数corePoolSize核心线程数。即使线程空闲也保持存活的线程数量。maximumPoolSize最大线程数。线程池中允许存在的最大线程数量。keepAliveTime非核心线程的空闲存活时间。超过该时间多余的线程会被回收。unitkeepAliveTime的时间单位。workQueue任务队列。当线程数达到核心线程数后新任务会放入该队列等待。threadFactory线程工厂。用于创建新线程可以在这里统一设置线程名、是否守护线程。handler拒绝策略。当队列满了且线程数达到最大值时对新任务的处理策略。有一个高频易混点allowCoreThreadTimeOut。如果设置为true核心线程在空闲超过keepAliveTime后也会被回收线程池的线程数最终可以降到0。这个参数默认是false核心线程即使空闲也不会被销毁。4.2 任务提交后线程池内部的工作流程整个执行流程按照官方源码的实现逻辑可以梳理成四条路径按优先级判断当线程数小于corePoolSize时即使工作线程处于空闲状态也会创建新线程处理任务而不是让空闲线程去取任务。这是很多初学者容易搞反的地方——线程池优先加人而不是排队。当线程数达到corePoolSize后新任务会尝试放入workQueue队列而不是直接创建新线程。当队列已满时才会创建非核心线程去处理任务。当线程数达到maximumPoolSize且队列也满了再提交新任务就触发拒绝策略。这里有一个关键语义execute和submit的区别。execute是Executor接口的方法参数是Runnable没有返回值任务异常会直接抛出submit是ExecutorService接口的方法参数可以是Runnable或Callable返回Future对象任务异常会被封装在Future里只有调用future.get()时才会以ExecutionException的形式抛出。用submit时如果业务上不关心执行结果也一定要调用get()来让异常暴露出来否则异常会静默丢失排查问题时会非常痛苦。4.3 四种拒绝策略与实战选型JDK内置了四种拒绝策略都实现RejectedExecutionHandler接口策略说明适用场景AbortPolicy直接抛出RejectedExecutionException默认策略适合任务必须执行成功否则及时暴露问题的场景CallerRunsPolicy由提交任务的线程自己执行任务适合不希望任务丢失、又能接受执行线程被占用的场景DiscardPolicy直接丢弃新任务适合可以容忍偶发丢消息的场景DiscardOldestPolicy丢弃队列中最老的任务重新尝试提交适合追求数据时效性的场景比如实时监控数据我见过很多团队直接默认AbortPolicy结果线上流量一打满就频繁抛异常业务直接报错。这个选择其实应该结合业务容忍度来定。比如日志上报、打点统计这类允许丢数据的场景用DiscardPolicy或DiscardOldestPolicy更合适至少不会影响主流程如果是用户下单、支付回调这类核心链路用CallerRunsPolicy能起到天然降级的效果——由调用方线程执行任务相当于把压力回抛给了上游。4.4 生产环境线程池配置的经验值线程池的配置没有万能公式但有一套可靠的估算思路。核心依据是任务的性质CPU密集型还是IO密集型。CPU密集型任务线程数建议设置为CPU核心数加1N1。加1的原因是为了防止某个线程因缺页中断、外部因素暂停时CPU有一个替补线程保证利用率。8核机器配9个线程是比较合理的。IO密集型任务线程数建议设置为CPU核心数的2倍2N。因为IO等待期间CPU是空闲的可以切换给其他线程执行。如果IO等待时间占比很高可以用更精细的公式线程数 CPU核心数 * (1 等待时间 / 计算时间)。假设一个任务CPU计算占比20%、IO等待占比80%那么8核机器理论上可以配置40个线程。关于队列容量我见过两种不同的取向。小队列如100以内配合largePoolSize适合要求响应时间短的场景——宁可多开线程也不让任务堆积大队列如1000以上配合小池子适合后台批量处理任务——允许一定程度的延迟但要防止任务积压导致内存溢出。设置线程名是另一个容易被忽略的细节。用自定义ThreadFactory给每个线程起一个带业务含义的名字比如order-pool-thread-1。这样线上出了问题jstack的线程dump里能一眼定位是哪个线程池的线程在卡什么位置排查效率提升不是一点点。5. ThreadLocal看似简单实则坑多ThreadLocal在面试里出镜率极高但很多人只停留在它是一个线程本地变量这个层面稍微往深里问两句就顶不住了。这块内容值得单独拎出来讲透。5.1 原理每个Thread一个MapThreadLocal的设计思路是这样的每个Thread对象内部都维护了一个ThreadLocalMap这个Map的key是ThreadLocal对象的弱引用WeakReferencevalue是线程自己存入的变量副本。当你调用threadLocal.set(value)时实际上是在当前线程的ThreadLocalMap里以这个ThreadLocal对象为key存了一个value调用threadLocal.get()时就是从这个Map里取key对应的value。所以ThreadLocal本质上并不是把变量存在某个独立空间里而是存在各个线程各自持有的Map里。线程之间天然隔离因此ThreadLocal最经典的用途是解决数据库连接、Session管理等工具类的线程安全问题让每个线程持有自己的连接副本互不干扰。有一个常被拿来对比的点是ThreadLocal和synchronized的区别。synchronized是在多个线程之间共享一个变量通过竞争锁来保证互斥ThreadLocal是每个线程各持有一份变量副本从根本上避免了竞争。一个是时间换空间串行执行一个是空间换时间并行执行。5.2 内存泄漏问题与remove的必要性ThreadLocal的内存泄漏问题是面试官最爱的追问点。问题出在ThreadLocalMap的Entry上。Entry继承了WeakReferencekey是ThreadLocal的弱引用。ThreadLocal对象被置为null后由于key是弱引用下一次GC时key就会被回收但value仍然是强引用被Entry强引用着。而Entry在ThreadLocalMap里ThreadLocalMap在Thread对象里。如果这个线程一直存活比如线程池里的线程value就永远没有机会被回收内存泄漏随之产生。为什么key要设计成弱引用这是有意为之。用弱引用是为了让ThreadLocal对象能被GC回收避免ThreadLocal本身的生命周期被线程持有造成ThreadLocal对象本身泄漏。但是换来了key被回收后、key为null的Entry还留在Map里的问题value还是被强引用着。解决方案很简单每次使用完ThreadLocal后调用remove()方法主动清理掉当前线程持有的这个Entry。这是规范写法不是可选操作。尤其在线程池场景下线程是复用的如果不remove线程的下一次任务可能会读到上一次任务残留的值这比内存泄漏还危险。5.3 线程池场景下的穿透问题线程池和ThreadLocal的组合是生产环境里最容易踩的隐形坑。假设你用一个线程池处理请求每个请求进来时往ThreadLocal里set了用户上下文处理完没remove。由于线程池里的线程是复用的下一个被这个线程处理的任务一进业务代码就能get到上一个任务留下的用户信息造成数据串线。这个问题的严重性在于它不是必现的而是概率性的——只有当同一个线程先后处理两个不同用户的任务时才会出问题。线上偶发出现用户A看到了用户B的数据这类诡异故障很多时候就是这个原因。我踩过这个坑之后总结了一套防御性写法在任务的最外层入口用try-finally包裹finally里强制remove或者干脆用一个装饰器模式把Runnable包一层在执行前调用ThreadLocalUtil.clear()。只要保证set必有对应的clear就能从源头杜绝穿透。另外配得上一个专门提醒的是继承性问题。InheritableThreadLocal可以让子线程继承父线程的ThreadLocal值Spring的异步注解在默认情况下从主线程提交到线程池的任务是拿不到主线程ThreadLocal值的。如果业务上需要请使用TransmittableThreadLocal阿里开源它专门解决线程池场景下的值传递问题。6. 并发容器与工具类怎么答才不像背答案Java并发包里的容器和工具类是八股文面试的重灾区。八成的人能说出ConcurrentHashMap是分段锁、CopyOnWriteArrayList是写时复制但再往深处问JDK 8的ConcurrentHashMap为什么改成CAS加synchronized、扩容时期get的语义是什么就答不出来了。这里挑几个核心的展开讲。6.1 ConcurrentHashMap的演进从分段锁到CAS加synchronizedConcurrentHashMap在JDK 7和JDK 8之间是一次彻底的重写理解这个演进过程比死记API有用得多。JDK 7的ConcurrentHashMap采用分段锁Segment思路。它把整个哈希表分成16个Segment默认并发级别每个Segment是一把独立的ReentrantLock内部维护一个Node数组。写操作只需要锁住对应的Segment其他Segment的读写不受影响。这样理论上支持16个线程并发写。JDK 8的ConcurrentHashMap放弃了分段锁结构与HashMap对齐为数组加链表加红黑树同步机制改为CAS加synchronized。具体来说数组元素为空时用CAS直接写入无锁完成。数组元素非空时用synchronized锁住数组下标的头节点保证对该桶的写入互斥。链表长度达到8时转为红黑树和HashMap一致树化阈值、链表阈值、扩容机制都与HashMap保持一致的约定。为什么JDK 8要抛弃分段锁我理解有两个原因一是Segment本身占用内存且并发粒度是Segment级别的即便不同key落在同一个Segment的不同桶上仍然会互相阻塞二是JDK 7的put流程里定位Segment之后拿到的锁竞争面仍然较大。JDK 8把锁粒度缩小到了单个桶table的某个index并发度从segment数提升到table长度级别内存占用也更小。还有一个面试细节get操作全程不加锁。它依靠Node节点的value使用volatile修饰来保证可见性同时依赖table引用用volatile修饰保证扩容后所有线程能看到新数组。读操作如果遇到哈希桶正好在扩容迁移中通过ForwardingNode转发到新的数组继续读语义上仍然能读到正确的值不保证读到最新但保证读到有效值。6.2 CountDownLatch、CyclicBarrier与Semaphore的差异化理解这三个工具类经常被放在一起考很多人背完定义就结束了但面试官更想听到的是你用过哪几个、在什么场景下用、为什么选它。CountDownLatch一个计数器初始化时设定计数值N。主线程调用await()等待其他线程每完成一个任务调用countDown()把计数减1计数归零时await()返回。它是一次性的计数归零后不能重置。典型场景启动一个服务时并发发起多个初始化任务等所有任务完成后继续向下执行。核心要点是它是一个门闩只向下计数不可逆。CyclicBarrier一个可循环使用的屏障。N个线程互相等待当所有线程都到达屏障点后屏障打开所有线程继续执行可触发一个barrierAction。它是可重置的适合多线程分批执行、每批之间需要同步的场景。典型场景多线程计算任务跑完一轮汇总结果后开始下一轮。Semaphore信号量维护一组许可证。线程acquire()获取许可证没有许可证就阻塞等待release()释放许可证。它可以控制同时访问某个资源的线程数量本质上是一个共享锁。典型场景限流——数据库连接池、接口并发数控制只允许N个线程同时访问资源。三者的记忆锚点CountDownLatch是倒数结束开门CyclicBarrier是人齐了再出发Semaphore是只有N个通行证领到才能进。6.3 面试官连环追问怎么接围绕并发容器面试官的追问通常集中在几个方向ConcurrentHashMap的size()怎么实现的 答案要点是JDK 8中size()先尝试无锁统计累加baseCount和CounterCell数组的计数如果竞争激烈会通过CounterCell分散计数最后再汇总。它的size是一个近似值并不保证强一致这符合ConcurrentHashMap弱一致性的设计哲学。CopyOnWriteArrayList为什么读多写少场景用它 答案要点是读操作不加锁直接访问内部数组写操作加锁并且每次修改时复制一份新数组修改完将volatile的数组引用指向新数组。代价是写操作的数组复制开销大且读到的可能是旧数据因此只适合读多写少、对数据实时性要求不高的场景。阻塞队列有哪些怎么选 答案要点是有界选ArrayBlockingQueue数组实现锁粒度是一个全局锁、无限趋近于无界选LinkedBlockingQueue链表实现默认Integer.MAX_VALUE容量生产上一定要配容量否则内存会爆、带优先级选PriorityBlockingQueue、延迟执行选DelayQueue、单生产者单消费者可以用SynchronousQueue不存储任务直接交接。如果你的回答能落到为什么这个场景选这个容器而不是一路背API面试官大概率会顺着你的思路多聊几句具体业务这反而是拿分的机会。7. 这些坑我踩过并发编程实战经验与避坑清单最后这部分我想分享一些实际开发中踩过的坑和排查思路。八股文背得再熟最终还是要落到解决真实问题上。7.1 我见过的并发Bug案例案例一双重检查锁的隐性疾病。有一次线上服务偶发对象状态不对排查到最后问题出在单例的实例化时序上。对象创建的三步分配内存、初始化、引用赋值不保证有序导致另一个线程拿到了非空但没有完成初始化的对象。当时代码里双重检查锁没加volatile加了volatile之后问题消失。这个坑在教科书里出现过无数次但现实中仍然高频。案例二HashMap在多线程下的死循环。有人在JDK 7的老项目里用HashMap做缓存并发put触发扩容结果导致CPU飙到100%jstack一看线程全部卡在HashMap的transfer方法里。JDK 7的HashMap扩容采用头插法多线程同时扩容时会形成环形链表get操作陷入死循环。这类问题在JDK 8里因为改成了尾插法有所缓解但并发场景下直接用线程安全的Map才是正确选择。案例三线程池任务异常被吞。我们在一个数据同步服务里遇到半夜任务静默失败日志里什么都没有。排查发现用的是execute方法提交任务Runnable内部try-catch没有捕获异常execute会把异常直接打印到标准错误流而服务没有收集stderr异常就人间蒸发。后来改成submit加get异常才完整暴露。7.2 排查并发问题的工具与方法并发问题的排查我的工具箱里有这么几样jstack查看线程状态和锁信息。死锁、线程卡死、锁竞争激烈导致的红色线程BLOCKED状态都能快速定位。jstat查看GC情况。很多并发问题其实是GC频繁触发引起的比如锁竞争激烈伴随大量线程阻塞内存里堆积了大量等待任务。JMCJava Mission Control飞行记录器可以录制一段时间的线程锁竞争、CPU采样、类加载信息适合排查偶发性问题。Arthas阿里开源的Java诊断工具。可以动态查看方法调用栈、方法执行耗时、甚至在线反编译。遇到线上并发问题需要挂日志重发这种僵局Arthas能直接介入不用改代码。排查思路有个固定套路先看线程状态分布是不是大量线程BLOCKED/WAITING再看锁竞争信息jstack里有没有明显的monitor竞争接着看GC和内存最后才是改代码验证。别一上来就猜靠数据定位。7.3 面试中怎么表达才能让面试官觉得你真懂同样是背八股文表达方式不同给面试官的印象天差地别。一个技巧是用现象到原理的顺序组织回答。被问到volatile时别先说volatile保证可见性和有序性而是说我在一个场景里遇到过值更新后读不到的问题排查后发现是缓存可见性问题最后用volatile解决因为volatile会插入内存屏障从JMM模型来看……——先描述真实的痛点和排查过程再引出原理听起来就是实操过而不是背过。另一个技巧是主动暴露边界。被问到CAS时主动补一句CAS有一个ABA问题如果业务上关心中间状态单靠CAS是不够的需要AtomicStampedReference。这既展示了知识深度也传递了我不仅知道原理还知道它的局限性的信号。最后一个技巧是讲到工具时带上取舍。被问到synchronized和ReentrantLock怎么选时不要只列对比加一句低竞争场景下两者性能差不多但synchronized不用手动释放锁不会被遗忘高竞争或需要超时、可中断时我会选ReentrantLock。这句话一出来面试官就知道你真的做过选型而不是只看过博客。7.4 给准备面试的同学一条主线建议如果你正在准备Java并发编程的面试我不建议按博客列表一篇篇刷。更高效的方式是先建立一条主线从并发编程要解决什么问题出发到volatile和synchronized怎么解决、底层原理是什么再到JUC的锁体系怎么把这些能力通用化然后是线程池和并发容器的实际工程选择最后落到排查工具和实战案例。这条主线串起来面试官无论从哪个点切入你都能沿着主干把答案展开不会出现背了十个知识点但串不出逻辑的尴尬。并发编程的八股文最大的价值不是帮助你通过面试而是逼你把为什么想清楚。想清楚了之后面试只是顺带的奖励。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门