并发编程 七

Java并发编程知识点

一、并发容器和框架:解决手动同步的痛点

1.1 核心定义

在Java多线程编程中,并发容器java.util.concurrent包下提供的线程安全数据结构,内部已实现完善的同步机制或非阻塞算法,开发者无需手动加锁即可在多线程环境下安全操作数据。并发框架则是用于简化并发任务执行的组件集合,包括线程池、ForkJoin框架、同步工具类等。

1.2 三大核心优势

  • 降低开发难度:避免手写同步代码带来的死锁、数据竞争等bug
  • 性能更优 :经过JDK团队高度优化,采用分段锁、CAS等技术,远优于粗暴的synchronized全表加锁
  • 功能丰富:覆盖了从数据存储到任务执行的全场景并发需求

1.3 整体架构概览

复制代码
java.util.concurrent
├── 并发容器
│   ├── 集合类:ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentSkipListMap
│   └── 队列类:BlockingQueue、ConcurrentLinkedQueue
├── 并发框架
│   ├── 线程池:ThreadPoolExecutor、ScheduledThreadPoolExecutor
│   └── ForkJoin框架:ForkJoinPool、RecursiveTask、RecursiveAction
└── 工具类
    ├── 原子类:AtomicInteger、AtomicReference
    └── 同步工具:CountDownLatch、CyclicBarrier、Semaphore

二、哈希表三巨头:HashMap、Hashtable与ConcurrentHashMap

这是Java面试100%必考点,必须彻底掌握三者的区别和底层实现。

2.1 核心对比表

特性 HashMap Hashtable ConcurrentHashMap
线程安全 ❌ 非线程安全 ✅ 线程安全(全表锁) ✅ 线程安全(精细化锁)
性能 极高 极差(单线程访问) 高(并发度高)
空键/空值 允许一个null键,多个null值 不允许任何null键或null值 不允许任何null键或null值
底层结构 JDK1.7:数组+链表 JDK1.8:数组+链表+红黑树 数组+链表 JDK1.7:分段锁(Segment数组) JDK1.8:数组+链表+红黑树+CAS+synchronized
推荐场景 单线程环境 已废弃,不推荐使用 多线程并发环境

2.2 HashMap的进化与线程安全问题

  • JDK 1.7 :采用头插法 插入元素,多线程扩容时会导致链表形成环形结构 ,后续get()操作会陷入死循环,CPU使用率飙升至100%
  • JDK 1.8 :改为尾插法 解决了环形链表问题,但仍存在数据覆盖 等线程安全问题。例如两个线程同时执行put()操作,可能导致其中一个线程的数据被覆盖

2.3 ConcurrentHashMap的实现原理(面试重中之重)

JDK 1.7:分段锁机制
  • 内部维护一个Segment数组,每个Segment继承自ReentrantLock,相当于一个独立的小哈希表
  • 加锁粒度是Segment级别,不同Segment的操作可以完全并发执行
  • 缺点:最多支持16个线程同时写(默认Segment数量为16),并发度有限
JDK 1.8:CAS+节点级锁
  • 彻底取消了Segment,改为对每个数组节点(桶)进行加锁
  • 当桶为空时,使用CAS操作插入元素,无需加锁
  • 当桶不为空时,使用synchronized锁定桶的头节点
  • 引入红黑树优化长链表查询,与HashMap保持一致
  • 优势:并发度大幅提升,理论上支持数组长度级别的并发写

2.4 面试高频追问

  • 为什么ConcurrentHashMap不允许null键和null值?
    为了避免歧义。如果get(key)返回null,无法判断是key不存在还是value本身就是null,在并发环境下这个问题会被放大。
  • ConcurrentHashMap的size()方法如何实现?
    JDK1.8采用先尝试无锁统计,失败后再加锁统计的方式,避免了1.7版本中加锁统计的性能问题。

三、哈希表底层原理与散列思想

3.1 哈希表的核心逻辑

哈希表之所以能实现O(1)时间复杂度的增删改查,核心在于散列技术

  1. 通过哈希函数将任意长度的键(key)转换为固定长度的哈希值
  2. 将哈希值对数组长度取模,得到元素应该存放的**桶(bucket)**位置
  3. 如果多个元素映射到同一个桶,使用链地址法解决冲突(链表或红黑树)

3.2 HashMap的哈希函数设计(JDK 1.8)

java 复制代码
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 将key的hashCode高16位与低16位进行异或运算
  • 目的:让高位也参与到桶位置的计算中,减少哈希冲突,使元素分布更均匀

3.3 散列思想的工程应用

散列不仅用于哈希表,更是分布式系统的核心思想:

  • 数据库分库分表:对主键哈希后取模,将数据分散到不同库表
  • 负载均衡:对客户端IP哈希后分配到不同服务器
  • 一致性哈希:解决分布式缓存的节点动态变化问题

四、HashMap多线程死循环问题深度解析

4.1 问题根源:JDK 1.7的头插法扩容

当HashMap元素数量超过阈值(容量×负载因子,默认0.75)时,会触发扩容:

  1. 创建一个容量为原来2倍的新数组
  2. 遍历旧数组,将每个元素重新哈希到新数组
  3. JDK 1.7采用头插法,将旧链表的节点依次插入新链表的头部

4.2 环形链表形成过程

假设两个线程同时对同一个HashMap进行扩容:

  1. 线程A执行到扩容代码的中间位置,被挂起
  2. 线程B完成扩容,将旧链表的节点重新排列
  3. 线程A恢复执行,继续按照原来的指针遍历
  4. 由于头插法的特性,两个节点会互相指向对方,形成环形链表

4.3 后果与解决方案

  • 后果 :后续调用get()方法查询该桶中的元素时,会陷入无限循环,CPU使用率飙升至100%
  • 解决方案
    • 多线程环境下绝对不要使用HashMap
    • 使用ConcurrentHashMap替代
    • 如果必须使用HashMap,可以用Collections.synchronizedMap()包装(性能较差)

五、阻塞队列:生产者消费者模式的最佳实践

5.1 核心特性

阻塞队列(BlockingQueue)是一种支持两个阻塞操作的队列:

  • 当队列满时,入队操作会阻塞,直到队列有空闲空间
  • 当队列空时,出队操作会阻塞,直到队列有元素

5.2 常见实现类对比

实现类 数据结构 有界性 特点 适用场景
ArrayBlockingQueue 数组 有界 必须指定容量,公平/非公平锁可选 大多数生产环境
LinkedBlockingQueue 链表 可选有界 默认无界,吞吐量高于ArrayBlockingQueue 需注意内存溢出风险
SynchronousQueue 无存储 特殊 不存储元素,每个入队必须等待一个出队 线程池newCachedThreadPool
PriorityBlockingQueue 无界 支持优先级排序 需要按优先级处理任务的场景

5.3 有界队列与无界队列的权衡

无界队列的风险

  • 生产者速度远快于消费者时,任务会无限堆积,最终导致OutOfMemoryError
  • 在线程池中使用无界队列时,线程池永远不会创建超过核心线程数的线程,任务响应时间会越来越长

有界队列的优势

  • 可以控制系统的资源使用,避免内存溢出
  • 当队列满时,可以触发线程池扩容或执行拒绝策略,保证系统的可控性

最佳实践 :生产环境中必须使用有界队列,并根据业务场景合理设置队列容量。

5.4 代码示例:阻塞队列实现生产者消费者

java 复制代码
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class BlockingQueueDemo {
    private static final BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5);
    
    public static void main(String[] args) {
        // 启动2个生产者线程
        for (int i = 0; i < 2; i++) {
            new Thread(new Producer(), "Producer-" + i).start();
        }
        
        // 启动3个消费者线程
        for (int i = 0; i < 3; i++) {
            new Thread(new Consumer(), "Consumer-" + i).start();
        }
    }
    
    static class Producer implements Runnable {
        @Override
        public void run() {
            int num = 0;
            while (true) {
                try {
                    queue.put(num); // 队列满时阻塞
                    System.out.println(Thread.currentThread().getName() + "生产:" + num);
                    num++;
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        }
    }
    
    static class Consumer implements Runnable {
        @Override
        public void run() {
            while (true) {
                try {
                    int num = queue.take(); // 队列空时阻塞
                    System.out.println(Thread.currentThread().getName() + "消费:" + num);
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        }
    }
}

六、ForkJoin框架:分而治之的并行计算利器

6.1 核心思想

ForkJoin框架是Java 7引入的并行计算框架,基于分而治之的思想:

  1. Fork:将一个大任务递归拆分成多个足够小的子任务
  2. Join:并行执行所有子任务,然后合并子任务的结果得到最终结果

6.2 工作窃取算法(Work-Stealing)

ForkJoin框架的高效性得益于工作窃取算法

  • 每个工作线程都有自己的双端队列,用于存储分配给自己的任务
  • 当一个线程完成自己队列中的所有任务后,会从其他线程队列的尾部窃取任务执行
  • 这种方式可以有效减少线程竞争,提高CPU利用率

6.3 代码示例:计算1到n的和

java 复制代码
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;

public class ForkJoinDemo extends RecursiveTask<Long> {
    private static final long THRESHOLD = 1000; // 任务拆分阈值
    private final long start;
    private final long end;
    
    public ForkJoinDemo(long start, long end) {
        this.start = start;
        this.end = end;
    }
    
    @Override
    protected Long compute() {
        // 如果任务足够小,直接计算
        if (end - start <= THRESHOLD) {
            long sum = 0;
            for (long i = start; i <= end; i++) {
                sum += i;
            }
            return sum;
        }
        
        // 否则拆分成两个子任务
        long mid = (start + end) / 2;
        ForkJoinDemo leftTask = new ForkJoinDemo(start, mid);
        ForkJoinDemo rightTask = new ForkJoinDemo(mid + 1, end);
        
        // 执行子任务
        leftTask.fork();
        rightTask.fork();
        
        // 合并子任务结果
        return leftTask.join() + rightTask.join();
    }
    
    public static void main(String[] args) {
        ForkJoinPool pool = new ForkJoinPool();
        long result = pool.invoke(new ForkJoinDemo(1, 1000000));
        System.out.println("计算结果:" + result);
    }
}

6.4 适用场景与限制

  • 适用场景:CPU密集型任务,尤其是递归分治类任务(如排序、矩阵运算、大数据处理)
  • 不适用场景:IO密集型任务(线程会阻塞,无法充分利用CPU)
  • 注意事项:子任务中不能执行阻塞操作,否则会导致工作线程无法执行其他任务

七、原子类与CAS机制:无锁线程安全的实现

7.1 原子类分类

java.util.concurrent.atomic包提供了多种原子类,用于实现无锁的线程安全操作:

  • 基本类型:AtomicInteger、AtomicLong、AtomicBoolean
  • 引用类型:AtomicReference、AtomicStampedReference、AtomicMarkableReference
  • 数组类型:AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
  • 字段更新器:AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater

7.2 CAS机制详解

CAS(Compare-And-Swap,比较并交换)是一种CPU原语,是实现原子类的基础:

  1. 它包含三个操作数:内存地址V、旧的预期值A、新值B
  2. 当且仅当内存地址V中的值等于预期值A时,将V的值更新为B
  3. 整个操作是原子的,不会被其他线程中断

AtomicInteger的incrementAndGet()方法内部就是一个典型的CAS自旋:

java 复制代码
public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

// Unsafe类中的getAndAddInt方法
public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        v = getIntVolatile(o, offset); // 读取当前值
    } while (!compareAndSwapInt(o, offset, v, v + delta)); // CAS尝试更新
    return v;
}

7.3 CAS的三大问题

  1. ABA问题 :一个值从A变为B又变回A,CAS会认为没有变化,但实际上已经发生了变化
    • 解决方案 :使用AtomicStampedReference(带版本号)或AtomicMarkableReference(带标记位)
  2. 自旋开销大 :高并发下CAS会频繁失败,导致大量自旋重试,浪费CPU资源
    • 解决方案:高竞争场景下使用锁替代
  3. 只能保证单个变量的原子性 :无法同时保证多个变量的原子操作
    • 解决方案:使用锁或将多个变量封装成一个对象,使用AtomicReference

7.4 ABA问题代码示例

java 复制代码
import java.util.concurrent.atomic.AtomicStampedReference;

public class ABADemo {
    private static final AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 0);
    
    public static void main(String[] args) throws InterruptedException {
        // 线程1:执行ABA操作
        Thread t1 = new Thread(() -> {
            int stamp = ref.getStamp();
            System.out.println("线程1初始版本号:" + stamp);
            ref.compareAndSet(100, 101, stamp, stamp + 1);
            ref.compareAndSet(101, 100, ref.getStamp(), ref.getStamp() + 1);
        });
        
        // 线程2:尝试更新
        Thread t2 = new Thread(() -> {
            int stamp = ref.getStamp();
            System.out.println("线程2初始版本号:" + stamp);
            try {
                Thread.sleep(1000); // 等待线程1完成ABA操作
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            boolean success = ref.compareAndSet(100, 200, stamp, stamp + 1);
            System.out.println("线程2更新是否成功:" + success);
            System.out.println("当前版本号:" + ref.getStamp());
        });
        
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}

执行结果:

复制代码
线程1初始版本号:0
线程2初始版本号:0
线程2更新是否成功:false
当前版本号:2

八、同步工具类:CountDownLatch与CyclicBarrier

8.1 CountDownLatch:一次性计数器

  • 作用:允许一个或多个线程等待其他线程完成一组操作
  • 原理:通过一个计数器实现,初始值为需要等待的线程数
  • 核心方法
    • countDown():计数器减1
    • await():等待计数器变为0
  • 特点:一次性使用,计数器变为0后无法重置

8.2 CyclicBarrier:循环屏障

  • 作用:允许一组线程互相等待,直到所有线程都到达屏障点
  • 原理:通过一个计数器实现,初始值为参与等待的线程数
  • 核心方法
    • await():线程到达屏障点,等待其他线程
    • reset():重置屏障,可以重复使用
  • 特点:可循环使用,支持在所有线程到达时执行一个额外的Runnable任务

8.3 核心区别对比表

特性 CountDownLatch CyclicBarrier
等待关系 主线程等待子线程 子线程互相等待
计数器 只能减少,一次性使用 可以重置,循环使用
额外任务 不支持 支持在所有线程到达时执行
适用场景 等待多个任务完成后汇总结果 并行迭代计算,多线程同步执行

8.4 CyclicBarrier代码示例:模拟运动员赛跑

java 复制代码
import java.util.concurrent.CyclicBarrier;

public class CyclicBarrierDemo {
    public static void main(String[] args) {
        int runnerCount = 5;
        // 创建CyclicBarrier,当所有运动员到达后执行发令枪任务
        CyclicBarrier barrier = new CyclicBarrier(runnerCount, () -> {
            System.out.println("所有运动员准备就绪,发令枪响!");
        });
        
        for (int i = 0; i < runnerCount; i++) {
            new Thread(new Runner(barrier), "运动员" + (i + 1)).start();
        }
    }
    
    static class Runner implements Runnable {
        private final CyclicBarrier barrier;
        
        public Runner(CyclicBarrier barrier) {
            this.barrier = barrier;
        }
        
        @Override
        public void run() {
            try {
                System.out.println(Thread.currentThread().getName() + "正在准备...");
                Thread.sleep((long) (Math.random() * 1000)); // 模拟准备时间
                System.out.println(Thread.currentThread().getName() + "准备完毕");
                barrier.await(); // 等待其他运动员
                System.out.println(Thread.currentThread().getName() + "开始跑步!");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

总结

本文总结了Java并发容器和框架中最核心的面试知识点,涵盖了并发容器、阻塞队列、ForkJoin框架、原子类与CAS、同步工具类等多个方面。在面试中,不仅要记住这些概念,更要理解其底层原理和适用场景,并能结合代码示例进行说明。

相关推荐
YikNjy2 小时前
string(c++)
java·服务器·c++
小江的记录本2 小时前
【Spring AI】Spring AI中RAG误触发与系统提示词泄露问题解决方案(完整版+代码方案)
java·人工智能·spring boot·后端·python·spring·面试
勇往直前plus2 小时前
Python 属性访问与操作全解析:内置函数、魔法方法与描述符深度指南
java·网络·python
Arenaschi2 小时前
关于GPT的版特点
java·网络·人工智能·windows·python·gpt
人道领域2 小时前
【LeetCode刷题日记】108.将有序数组转换为二叉搜索树
java·算法·leetcode
橙淮2 小时前
并发编程(五)
java
过期动态2 小时前
【LeetCode 热题 100】无重复字符的最长子串
java·数据结构·spring boot·算法·leetcode·职场和发展
Yeats_Liao3 小时前
好复杂的 IoT 世界:工业数据采集技术栈全景解析
java·物联网·struts
月落归舟3 小时前
Java线程小记
java·开发语言