JUC 并发工具箱:常见类、线程安全集合与死锁

java.util.concurrentJUC)可以理解成:多线程开发里“别手搓了,直接用标准件”的工具箱。来看三块最常用的内容:常见类线程安全集合死锁


1. JUC 的常见类:从“手动挡多线程”升级到“自动挡多线程”

1.1 Callable + FutureTask:把“返回值 + 等结果”这件事做正确

先看一个“传统手法”:子线程算完结果,主线程用 wait/notify 等结果。代码能写,但同步细节多、容易出错。

static class Result {
    public int sum = 0;
    public Object lock = new Object();
}

public static void main(String[] args) throws InterruptedException {
    Result result = new Result();

    Thread t = new Thread(() -> {
        int sum = 0;
        for (int i = 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);
    }
}

这里的关键点是:主线程要在 whilewait(),避免“被唤醒后条件其实仍不成立”的情况;而且还需要额外的 Result 辅助对象来承载共享数据和锁,整体复杂度偏高。

再看 JUC 的“标准答案”:Callable 负责“能返回结果的任务”FutureTask 负责“存结果 + 等结果”

Callable<Integer> callable = new Callable<Integer>() {
    @Override
    public Integer call() {
        int sum = 0;
        for (int i = 1; i <= 1000; i++) sum += i;
        return sum;
    }
};

FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();

int result = futureTask.get(); // 阻塞等待计算完成,并拿到返回值
System.out.println(result);

FutureTask 就像“取餐小票”:任务什么时候做完不确定,但小票在手,随时 get() 去等结果/拿结果。


1.2 ReentrantLock:更灵活的互斥锁(但也更考验手法)

ReentrantLocksynchronized 都是为了互斥(同一时刻只让一个线程进入临界区)。它的典型用法是:lock 之后必须 finally unlock,否则极容易漏掉解锁造成“锁永久不释放”。

ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    // 临界区:访问共享资源
} finally {
    lock.unlock();
}

它比 synchronized 更“可控”的点主要有这些:

  • synchronized 是 JVM 内部实现的关键字;ReentrantLock 是标准库类(JVM 外、Java 实现)。
  • synchronized 获取不到锁会“死等”;ReentrantLock 可以 tryLock(超时),等一会拿不到就放弃。
  • synchronized 是非公平锁;ReentrantLock 默认也非公平,但构造时传 true 可以开公平锁。
  • 等待/唤醒方面:synchronizedwait/notify,唤醒的是“随机等待线程”;ReentrantLock + Condition 可以更精确地控制唤醒哪个等待线程。

再给一个 tryLock 的味道(“死等” vs “等一会儿不行就撤”):

ReentrantLock lock = new ReentrantLock();

if (lock.tryLock()) {
    try {
        // 拿到锁了再干活
    } finally {
        lock.unlock();
    }
} else {
    // 拿不到锁:选择降级、重试、记录日志等
}

什么时候选哪个?一句话:竞争不激烈图省心 → synchronized;竞争激烈或需要超时/公平/精确唤醒 → ReentrantLock


1.3 原子类 AtomicX:用 CAS 做“无锁原子更新”

原子类内部依赖 CAS(Compare-And-Swap)实现,通常比“加锁做 i++”更高效。常见的有:

  • AtomicBoolean
  • AtomicInteger
  • AtomicIntegerArray
  • AtomicLong
  • AtomicReference
  • AtomicStampedReference(名字就暗示:它会带“戳”,常用来对付 ABA)

AtomicInteger 为例,常见方法和语义:

  • addAndGet(delta)i += delta
  • incrementAndGet()++i
  • getAndIncrement()i++
  • decrementAndGet()--i
  • getAndDecrement()i--

来看一个最常用的例子:并发计数。

AtomicInteger cnt = new AtomicInteger(0);

Runnable r = () -> {
    for (int i = 0; i < 100000; i++) {
        cnt.getAndIncrement();
    }
};
new Thread(r).start();
new Thread(r).start();

它背后的核心逻辑可以理解成一个 CAS 自旋(一直尝试,直到更新成功):

class AtomicInteger {
    private int value;

    public int getAndIncrement() {
        int oldValue = value;
        while (CAS(value, oldValue, oldValue + 1) != true) {
            oldValue = value;
        }
        return oldValue;
    }
}

注意这里的 CAS 体现的是“三元比较交换”:内存位置/当前值、期望旧值、新值,不匹配就重试。


1.4 线程池:ExecutorService / Executors / ThreadPoolExecutor

线程频繁创建销毁不划算,所以线程池的思路是:线程不用了先放“池子”里,下次直接复用。

最常见入口是:

  • ExecutorService:线程池实例
  • Executors:工厂类,快速创建不同风格线程池
  • submit(...):提交任务
ExecutorService pool = 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):

ExecutorService pool = new ThreadPoolExecutor(
        1, 2,
        1000, TimeUnit.MILLISECONDS,
        new SynchronousQueue<>(),
        Executors.defaultThreadFactory(),
        new ThreadPoolExecutor.AbortPolicy()
);

for (int i = 0; i < 3; i++) {
    pool.submit(() -> System.out.println("hello"));
}

任务超过负荷时,会按策略处理(这里是直接异常)。


1.5 Semaphore:为什么能像“锁”一样用?

Semaphore 是“可用资源个数”的计数器:acquire() 申请资源(P 操作),release() 释放资源(V 操作)。计数器减到 0 还要申请就会阻塞等待;更关键的是 PV 加减计数是原子的,所以可以直接多线程使用。

它能起到“类似锁”的作用,本质是:把“能同时进入临界区的人数”限制住。互斥锁是“最多 1 个”;信号量可以是“最多 N 个”(共享锁)。

Semaphore semaphore = new Semaphore(4); // 4 个“名额”

Runnable r = () -> {
    try {
        System.out.println("申请资源");
        semaphore.acquire();             // P:名额 -1(没有名额就阻塞)
        System.out.println("获取到资源");
        Thread.sleep(1000);
        System.out.println("释放资源");
        semaphore.release();             // V:名额 +1
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
};

for (int i = 0; i < 20; i++) {
    new Thread(r).start();
}

前 4 个线程能直接进入“临界区”,后面的线程会在 acquire() 阻塞,直到有人 release();这就是“共享锁”的味道。


1.6 CountDownLatch:等一堆线程干完活再继续

CountDownLatch 用来“同时等待 N 个任务结束”:构造时给一个计数 N;每个任务结束 countDown();主线程 await() 等计数归零。

CountDownLatch latch = new CountDownLatch(10);

Runnable r = () -> {
    try {
        Thread.sleep((long) (Math.random() * 10000));
        latch.countDown(); // 一个任务完成,计数 -1
    } catch (Exception e) {
        e.printStackTrace();
    }
};

for (int i = 0; i < 10; i++) {
    new Thread(r).start();
}

// 必须等到 10 个都完成
latch.await();
System.out.println("比赛结束");

这段代码的语义非常直白:“不到 10 人全回来,不公布成绩”


2. 线程安全的集合类:别拿 HashMap 去硬刚并发

2.1 先定个基调:哪些“天生线程安全”,哪些不是

很多集合默认不是线程安全的;但 Vector / Stack / Hashtable 是线程安全的(不过不太建议用),其他大多数集合类都不是线程安全的。


2.2 多线程环境下怎么用 ArrayList:三条路

路 1:自己加锁synchronizedReentrantLock):

List<Integer> list = new ArrayList<>();
Object lock = new Object();

Runnable r = () -> {
    for (int i = 0; i < 1000; i++) {
        synchronized (lock) {
            list.add(i);
        }
    }
};

路 2:Collections.synchronizedList(标准库给的“加了 synchronized 的 List 包装器”):

List<Integer> list = Collections.synchronizedList(new ArrayList<>());

Runnable r = () -> {
    for (int i = 0; i < 1000; i++) {
        list.add(i); // 内部关键方法都带 synchronized
    }
};

路 3:CopyOnWriteArrayList(写时复制:读写分离)

它的核心思想是:写的时候不在原容器上改,而是 copy 出新容器,写完再把引用指向新容器;读则读旧容器,因此读不需要加锁竞争,适合“读多写少”。代价也很明显:更吃内存,而且新写入的数据不会第一时间被读到。

List<Integer> list = new CopyOnWriteArrayList<>();

// 读多写少的场景更合适
list.add(1);
System.out.println(list.get(0));

2.3 多线程环境下用队列:BlockingQueue 家族直接上

多线程里最常见的模式之一是“生产者-消费者”,这时候用阻塞队列非常省心。常见阻塞队列包括:

  • ArrayBlockingQueue:数组实现
  • LinkedBlockingQueue:链表实现
  • PriorityBlockingQueue:堆实现(带优先级)
  • TransferQueue:交接型队列(用来做更强的“线程间移交”)

来看一个最经典的生产者-消费者:

BlockingQueue<String> q = new ArrayBlockingQueue<>(3);

// 生产者:放
new Thread(() -> {
    try {
        for (int i = 0; i < 10; i++) {
            q.put("msg-" + i); // 满了会阻塞
            System.out.println("put " + i);
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}).start();

// 消费者:取
new Thread(() -> {
    try {
        while (true) {
            String v = q.take(); // 空了会阻塞
            System.out.println("take " + v);
        }
    } catch (InterruptedException e) {
        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 = new ConcurrentHashMap<>();

// 并发下“如果没有就放一个默认值”常用 computeIfAbsent
map.computeIfAbsent("k", key -> 0);

// 原子式更新(避免 read-modify-write 的竞态)
map.compute("k", (key, oldVal) -> oldVal + 1);

3. 死锁:线程界的“互相礼让到世界毁灭”

3.1 死锁是什么:线程都卡住了,程序不可能正常结束

死锁就是:多个线程同时被阻塞,一个或全部都在等待某个资源被释放,结果谁也不撒手,线程无限期阻塞,程序无法正常终止。

理解死锁最形象的例子:吃饺子要酱油和醋,两个人一人拿一个,都要求对方先给自己——互不相让,直接卡死。酱油/醋是两把锁,两个人是两个线程。

进一步还有经典“哲学家就餐问题”:如果大家同一时刻都先拿左边筷子,再拿右边筷子,就会发现右边都被占了,于是全员等待,全员死锁。


3.2 死锁产生的四个必要条件(面试必背但更要会用)

死锁成立需要四个条件同时满足:

  1. 互斥使用:资源被一个线程占有时,其他线程不能用
  2. 不可抢占:资源只能由占有者主动释放,不能强抢
  3. 请求并保持:拿着已有资源,还要继续请求新的资源
  4. 循环等待:形成环路:P1 等 P2 的资源,P2 等 P3 的资源,…,Pn 又等回 P1

只要打破任意一个条件,死锁就消失。最容易下手的是:破坏循环等待


3.3 如何避免死锁:锁排序(Lock Ordering)

最常用的办法:给锁编号(1…M),所有线程必须按编号从小到大加锁,这样就不会形成等待环路。

来看一段“容易死锁”的代码:两个线程加锁顺序相反。

Object lock1 = new Object();
Object lock2 = new Object();

Thread t1 = new Thread(() -> {
    synchronized (lock1) {
        synchronized (lock2) {
            // do something...
        }
    }
});

Thread t2 = new Thread(() -> {
    synchronized (lock2) {
        synchronized (lock1) {
            // do something...
        }
    }
});

t1.start();
t2.start();

如果 t1 拿到 lock1、t2 拿到 lock2,然后双方都去等对方的第二把锁,就卡死。

修复方式就是“约定顺序”:都先 lock1 再 lock2。

Object lock1 = new Object();
Object lock2 = new Object();

Thread t1 = new Thread(() -> {
    synchronized (lock1) {
        synchronized (lock2) {
            // do something...
        }
    }
});

Thread t2 = new Thread(() -> {
    synchronized (lock1) {
        synchronized (lock2) {
            // do something...
        }
    }
});

t1.start();
t2.start();

锁顺序一致,就不会出现环路等待。


总结

  • 需要“有返回值的任务 + 等结果” → Callable + FutureTask(同步细节少很多)。
  • 读多写少的共享 List → CopyOnWriteArrayList;生产者消费者 → BlockingQueue 家族;并发 Map → ConcurrentHashMap
  • 看到“多把锁 + 加锁顺序不一致”就要警觉:锁排序是最常用的死锁规避手段。
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐