JUC 并发工具箱:常见类、线程安全集合与死锁
java.util.concurrent(JUC)可以理解成:多线程开发里“别手搓了,直接用标准件”的工具箱。来看三块最常用的内容:常见类、线程安全集合、死锁。
1. JUC 的常见类:从“手动挡多线程”升级到“自动挡多线程”
1.1 Callable + FutureTask:把“返回值 + 等结果”这件事做正确
先看一个“传统手法”:子线程算完结果,主线程用wait/notify等结果。代码能写,但同步细节多、容易出错。
staticclassResult{publicintsum=0;publicObjectlock=newObject();}publicstaticvoidmain(String[]args)throwsInterruptedException{Resultresult=newResult();Threadt=newThread(()->{intsum=0;for(inti=1;i<=1000;i++)sum+=i;synchronized(result.lock){result.sum=sum;result.lock.notify();}});t.start();synchronized(result.lock){while(result.sum==0){result.lock.wait();}System.out.println(result.sum);}}这里的关键点是:主线程要在while里wait(),避免“被唤醒后条件其实仍不成立”的情况;而且还需要额外的Result辅助对象来承载共享数据和锁,整体复杂度偏高。
再看 JUC 的“标准答案”:Callable 负责“能返回结果的任务”,FutureTask 负责“存结果 + 等结果”。
Callable<Integer>callable=newCallable<Integer>(){@OverridepublicIntegercall(){intsum=0;for(inti=1;i<=1000;i++)sum+=i;returnsum;}};FutureTask<Integer>futureTask=newFutureTask<>(callable);Threadt=newThread(futureTask);t.start();intresult=futureTask.get();// 阻塞等待计算完成,并拿到返回值System.out.println(result);FutureTask就像“取餐小票”:任务什么时候做完不确定,但小票在手,随时get()去等结果/拿结果。
1.2 ReentrantLock:更灵活的互斥锁(但也更考验手法)
ReentrantLock和synchronized都是为了互斥(同一时刻只让一个线程进入临界区)。它的典型用法是:lock 之后必须 finally unlock,否则极容易漏掉解锁造成“锁永久不释放”。
ReentrantLocklock=newReentrantLock();lock.lock();try{// 临界区:访问共享资源}finally{lock.unlock();}它比synchronized更“可控”的点主要有这些:
synchronized是 JVM 内部实现的关键字;ReentrantLock是标准库类(JVM 外、Java 实现)。synchronized获取不到锁会“死等”;ReentrantLock可以tryLock(超时),等一会拿不到就放弃。synchronized是非公平锁;ReentrantLock默认也非公平,但构造时传true可以开公平锁。- 等待/唤醒方面:
synchronized用wait/notify,唤醒的是“随机等待线程”;ReentrantLock + Condition可以更精确地控制唤醒哪个等待线程。
再给一个tryLock的味道(“死等” vs “等一会儿不行就撤”):
ReentrantLocklock=newReentrantLock();if(lock.tryLock()){try{// 拿到锁了再干活}finally{lock.unlock();}}else{// 拿不到锁:选择降级、重试、记录日志等}什么时候选哪个?一句话:竞争不激烈图省心 → synchronized;竞争激烈或需要超时/公平/精确唤醒 → ReentrantLock。
1.3 原子类 AtomicX:用 CAS 做“无锁原子更新”
原子类内部依赖CAS(Compare-And-Swap)实现,通常比“加锁做 i++”更高效。常见的有:
AtomicBooleanAtomicIntegerAtomicIntegerArrayAtomicLongAtomicReferenceAtomicStampedReference(名字就暗示:它会带“戳”,常用来对付 ABA)
以AtomicInteger为例,常见方法和语义:
addAndGet(delta):i += deltaincrementAndGet():++igetAndIncrement():i++decrementAndGet():--igetAndDecrement():i--
来看一个最常用的例子:并发计数。
AtomicIntegercnt=newAtomicInteger(0);Runnabler=()->{for(inti=0;i<100000;i++){cnt.getAndIncrement();}};newThread(r).start();newThread(r).start();它背后的核心逻辑可以理解成一个 CAS 自旋(一直尝试,直到更新成功):
classAtomicInteger{privateintvalue;publicintgetAndIncrement(){intoldValue=value;while(CAS(value,oldValue,oldValue+1)!=true){oldValue=value;}returnoldValue;}}注意这里的 CAS 体现的是“三元比较交换”:内存位置/当前值、期望旧值、新值,不匹配就重试。
1.4 线程池:ExecutorService / Executors / ThreadPoolExecutor
线程频繁创建销毁不划算,所以线程池的思路是:线程不用了先放“池子”里,下次直接复用。
最常见入口是:
ExecutorService:线程池实例Executors:工厂类,快速创建不同风格线程池submit(...):提交任务
ExecutorServicepool=Executors.newFixedThreadPool(10);pool.submit(()->{System.out.println("hello");});Executors常见创建方式包括:
newFixedThreadPool:固定线程数newCachedThreadPool:线程数动态增长newSingleThreadExecutor:单线程newScheduledThreadPool:延迟/定时执行(Timer 的进阶版)
更可控的底层是ThreadPoolExecutor。理解它的参数,可以用“开公司招人”类比:
corePoolSize:正式员工数maximumPoolSize:正式员工 + 临时工上限keepAliveTime+unit:临时工空闲多久就辞退workQueue:任务队列(阻塞队列)threadFactory:线程工厂RejectedExecutionHandler:忙不过来时的拒绝策略
拒绝策略常见四种:
AbortPolicy():直接抛异常CallerRunsPolicy():调用者自己执行DiscardOldestPolicy():丢队列里最老的任务DiscardPolicy():丢新来的任务
来看一个带拒绝策略的ThreadPoolExecutor示例(这里用SynchronousQueue配合AbortPolicy):
ExecutorServicepool=newThreadPoolExecutor(1,2,1000,TimeUnit.MILLISECONDS,newSynchronousQueue<>(),Executors.defaultThreadFactory(),newThreadPoolExecutor.AbortPolicy());for(inti=0;i<3;i++){pool.submit(()->System.out.println("hello"));}任务超过负荷时,会按策略处理(这里是直接异常)。
1.5 Semaphore:为什么能像“锁”一样用?
Semaphore是“可用资源个数”的计数器:acquire()申请资源(P 操作),release()释放资源(V 操作)。计数器减到 0 还要申请就会阻塞等待;更关键的是PV 加减计数是原子的,所以可以直接多线程使用。
它能起到“类似锁”的作用,本质是:把“能同时进入临界区的人数”限制住。互斥锁是“最多 1 个”;信号量可以是“最多 N 个”(共享锁)。
Semaphoresemaphore=newSemaphore(4);// 4 个“名额”Runnabler=()->{try{System.out.println("申请资源");semaphore.acquire();// P:名额 -1(没有名额就阻塞)System.out.println("获取到资源");Thread.sleep(1000);System.out.println("释放资源");semaphore.release();// V:名额 +1}catch(InterruptedExceptione){e.printStackTrace();}};for(inti=0;i<20;i++){newThread(r).start();}前 4 个线程能直接进入“临界区”,后面的线程会在acquire()阻塞,直到有人release();这就是“共享锁”的味道。
1.6 CountDownLatch:等一堆线程干完活再继续
CountDownLatch用来“同时等待 N 个任务结束”:构造时给一个计数 N;每个任务结束countDown();主线程await()等计数归零。
CountDownLatchlatch=newCountDownLatch(10);Runnabler=()->{try{Thread.sleep((long)(Math.random()*10000));latch.countDown();// 一个任务完成,计数 -1}catch(Exceptione){e.printStackTrace();}};for(inti=0;i<10;i++){newThread(r).start();}// 必须等到 10 个都完成latch.await();System.out.println("比赛结束");这段代码的语义非常直白:“不到 10 人全回来,不公布成绩”。
2. 线程安全的集合类:别拿 HashMap 去硬刚并发
2.1 先定个基调:哪些“天生线程安全”,哪些不是
很多集合默认不是线程安全的;但Vector / Stack / Hashtable是线程安全的(不过不太建议用),其他大多数集合类都不是线程安全的。
2.2 多线程环境下怎么用 ArrayList:三条路
路 1:自己加锁(synchronized或ReentrantLock):
List<Integer>list=newArrayList<>();Objectlock=newObject();Runnabler=()->{for(inti=0;i<1000;i++){synchronized(lock){list.add(i);}}};路 2:Collections.synchronizedList(标准库给的“加了 synchronized 的 List 包装器”):
List<Integer>list=Collections.synchronizedList(newArrayList<>());Runnabler=()->{for(inti=0;i<1000;i++){list.add(i);// 内部关键方法都带 synchronized}};路 3:CopyOnWriteArrayList(写时复制:读写分离)
它的核心思想是:写的时候不在原容器上改,而是 copy 出新容器,写完再把引用指向新容器;读则读旧容器,因此读不需要加锁竞争,适合“读多写少”。代价也很明显:更吃内存,而且新写入的数据不会第一时间被读到。
List<Integer>list=newCopyOnWriteArrayList<>();// 读多写少的场景更合适list.add(1);System.out.println(list.get(0));2.3 多线程环境下用队列:BlockingQueue 家族直接上
多线程里最常见的模式之一是“生产者-消费者”,这时候用阻塞队列非常省心。常见阻塞队列包括:
ArrayBlockingQueue:数组实现LinkedBlockingQueue:链表实现PriorityBlockingQueue:堆实现(带优先级)TransferQueue:交接型队列(用来做更强的“线程间移交”)
来看一个最经典的生产者-消费者:
BlockingQueue<String>q=newArrayBlockingQueue<>(3);// 生产者:放newThread(()->{try{for(inti=0;i<10;i++){q.put("msg-"+i);// 满了会阻塞System.out.println("put "+i);}}catch(InterruptedExceptione){e.printStackTrace();}}).start();// 消费者:取newThread(()->{try{while(true){Stringv=q.take();// 空了会阻塞System.out.println("take "+v);}}catch(InterruptedExceptione){e.printStackTrace();}}).start();这类代码的好处是:阻塞/唤醒由队列内部完成,业务线程不用手搓wait/notify。
2.4 多线程环境下用哈希表:Hashtable vs ConcurrentHashMap
HashMap不是线程安全的。并发场景下常用两种:
Hashtable:给关键方法加了synchronized,锁住的是整个Hashtable对象,效率偏低,key 不允许为 null。ConcurrentHashMap:线程安全,并且为了降低锁竞争做了不少优化:- JDK 1.7 用“分段锁”(Segment)降低冲突概率(同段才竞争)。
- JDK 1.8 取消分段锁,改为“每个桶/链表一把锁”(以链表头结点作为锁对象);结构从“数组+链表”升级为“数组+链表/红黑树”,链表长到一定程度(≥8)会转红黑树;并提到会充分利用 CAS、优化扩容方式,key 不允许为 null。
看一个并发 map 的典型用法:
ConcurrentHashMap<String,Integer>map=newConcurrentHashMap<>();// 并发下“如果没有就放一个默认值”常用 computeIfAbsentmap.computeIfAbsent("k",key->0);// 原子式更新(避免 read-modify-write 的竞态)map.compute("k",(key,oldVal)->oldVal+1);3. 死锁:线程界的“互相礼让到世界毁灭”
3.1 死锁是什么:线程都卡住了,程序不可能正常结束
死锁就是:多个线程同时被阻塞,一个或全部都在等待某个资源被释放,结果谁也不撒手,线程无限期阻塞,程序无法正常终止。
理解死锁最形象的例子:吃饺子要酱油和醋,两个人一人拿一个,都要求对方先给自己——互不相让,直接卡死。酱油/醋是两把锁,两个人是两个线程。
进一步还有经典“哲学家就餐问题”:如果大家同一时刻都先拿左边筷子,再拿右边筷子,就会发现右边都被占了,于是全员等待,全员死锁。
3.2 死锁产生的四个必要条件(面试必背但更要会用)
死锁成立需要四个条件同时满足:
- 互斥使用:资源被一个线程占有时,其他线程不能用
- 不可抢占:资源只能由占有者主动释放,不能强抢
- 请求并保持:拿着已有资源,还要继续请求新的资源
- 循环等待:形成环路:P1 等 P2 的资源,P2 等 P3 的资源,…,Pn 又等回 P1
只要打破任意一个条件,死锁就消失。最容易下手的是:破坏循环等待。
3.3 如何避免死锁:锁排序(Lock Ordering)
最常用的办法:给锁编号(1…M),所有线程必须按编号从小到大加锁,这样就不会形成等待环路。
来看一段“容易死锁”的代码:两个线程加锁顺序相反。
Objectlock1=newObject();Objectlock2=newObject();Threadt1=newThread(()->{synchronized(lock1){synchronized(lock2){// do something...}}});Threadt2=newThread(()->{synchronized(lock2){synchronized(lock1){// do something...}}});t1.start();t2.start();如果 t1 拿到 lock1、t2 拿到 lock2,然后双方都去等对方的第二把锁,就卡死。
修复方式就是“约定顺序”:都先 lock1 再 lock2。
Objectlock1=newObject();Objectlock2=newObject();Threadt1=newThread(()->{synchronized(lock1){synchronized(lock2){// do something...}}});Threadt2=newThread(()->{synchronized(lock1){synchronized(lock2){// do something...}}});t1.start();t2.start();锁顺序一致,就不会出现环路等待。
总结
- 需要“有返回值的任务 + 等结果” →
Callable + FutureTask(同步细节少很多)。 - 读多写少的共享 List →
CopyOnWriteArrayList;生产者消费者 →BlockingQueue家族;并发 Map →ConcurrentHashMap。 - 看到“多把锁 + 加锁顺序不一致”就要警觉:锁排序是最常用的死锁规避手段。