资讯动态

Java并发与多线程32个核心考点:从面试到生产实战

发布时间:2026/10/9 17:53:43 来源:尧图企业网站定制
1. 为什么并发与多线程是Java面试的“必争之地”干了这么多年Java我越来越觉得并发和多线程这块知识就像是一道分水岭。初级开发能写个Thread、知道synchronized怎么用就算过关但到了中高级岗位面试官随便抛出一个“AQS底层怎么实现的”“CAS的ABA问题怎么解”“线程池参数怎么配”立马就能筛掉一大半人。这不是故意刁难而是因为在实际生产环境中高并发场景下的Bug往往最隐蔽、最难排查一个线程安全问题可能导致整个系统雪崩。我面过不少人也被人面过很多次。有个很深的体会并发这块知识光靠背八股文是过不了关的。面试官只要多追问一层“为什么”比如“为什么用ConcurrentHashMap而不是Collections.synchronizedMap”“为什么volatile不能保证原子性”那些只背了结论的人马上就露馅了。所以这篇文章我想从实际面试和工作中积累的经验出发把32个核心考点串起来讲透不光告诉你答案是什么更要讲清楚背后的设计思想和适用场景。这篇文章适合谁看如果你是准备跳槽的Java工程师不管是三年经验还是五年经验只要面试岗位涉及高并发、分布式系统这些考点你都得吃透。如果你是在校生或者刚入行的新人想提前了解企业级开发中真正用到的并发知识这篇文章也能帮你建立完整的知识框架。我会尽量用大白话把复杂概念讲清楚同时配上代码示例和参数计算过程让你不仅能应付面试更能把这些知识用到实际项目里去。2. 并发基础概念与核心理论拆解2.1 进程、线程与协程的本质区别很多人面试时被问到“进程和线程的区别”张口就是“进程是资源分配的基本单位线程是CPU调度的基本单位”。这个答案没错但太教科书了。我从实际开发的角度给你翻译一下进程就像一栋独立的房子有自己的地址空间、文件描述符、内存区域线程则是房子里的不同房间共享客厅和厨房堆内存、方法区但每个房间有自己的床和衣柜栈内存、程序计数器。那协程呢协程是用户态的轻量级线程它的调度完全由程序自己控制不经过操作系统内核。Java 19引入的虚拟线程Virtual Threads就是一种协程的实现。我实测过在IO密集型任务中用虚拟线程可以轻松创建百万级别的并发任务而传统平台线程创建几千个就可能因为内存耗尽而崩溃。为什么因为平台线程的栈默认是1MB而虚拟线程的栈是动态伸缩的初始只有几百字节。注意虚拟线程不适合CPU密集型任务因为它的优势在于IO等待时的线程切换而不是计算能力的提升。2.2 并发三大特性的通俗解读原子性、可见性、有序性这三个词面试必考。我用一个生活化的例子来解释假设你和室友共用一张银行卡卡里有1000块钱。你取100块室友也取100块如果取款操作不是原子的就可能出现两人同时读到余额1000然后各自扣减100最后卡里还剩900而不是800。这就是原子性问题。可见性呢你在房间里改了空调温度但室友在客厅看不到还以为温度没变。对应到Java里就是一个线程修改了共享变量另一个线程却读到了旧值。volatile关键字就是用来解决这个问题的它强制线程每次读变量都从主内存读写变量立刻刷回主内存。有序性最容易被忽略。编译器为了优化性能可能会调整指令执行顺序。比如你写int a 1; int b 2;编译器可能先执行b 2再执行a 1。单线程下没问题但多线程下就可能出乱子。volatile和synchronized都能在一定程度上禁止指令重排序。2.3 JMM内存模型到底在讲什么JMMJava Memory Model是并发编程的基石但很多人学得云里雾里。我换个角度讲JMM定义了一套规则规定了多线程环境下一个线程对共享变量的写入何时对另一个线程可见。它抽象出了主内存和本地内存的概念——每个线程有自己的本地内存对应CPU缓存和寄存器共享变量存在主内存里。JMM的核心规则围绕happens-before原则展开。这个原则说白了就是如果操作A happens-before 操作B那么A的结果对B一定可见。具体包括程序顺序规则同一线程内前面的操作happens-before后面的、监视器锁规则解锁happens-before后续加锁、volatile变量规则写volatile变量happens-before后续读该变量、传递性规则等。我踩过的一个坑曾经写过一个单例模式用了双重检查锁定但忘了给实例变量加volatile。结果在压力测试时偶尔会抛NPE。原因就是指令重排序导致对象还没初始化完引用就已经被赋值了。加上volatile之后问题消失。这个案例我在面试中经常拿来问候选人能讲清楚的人不多。3. 线程生命周期与核心API实操解析3.1 线程的六种状态与转换条件Java中Thread.State枚举定义了六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。面试官常问“线程调用了wait()之后是什么状态”答案是WAITING调用了sleep(1000)呢是TIMED_WAITING。这两个状态的区别在于是否会被自动唤醒。状态转换的完整路径你得记清楚NEW状态调用start()进入RUNNABLERUNNABLE遇到synchronized竞争失败进入BLOCKED调用wait()或join()进入WAITING调用sleep()或带超时参数的wait()进入TIMED_WAITING线程执行完毕或异常退出进入TERMINATED。实操心得线上排查问题时用jstack打印线程堆栈看到大量BLOCKED状态的线程基本可以断定有锁竞争问题看到大量WAITING状态可能是线程池配置不合理或者有死锁。3.2 创建线程的四种方式及选型建议继承Thread类、实现Runnable接口、实现Callable接口配合FutureTask、使用线程池。这四种方式面试必问但更重要的是知道什么时候用哪种。继承Thread类的方式最不推荐因为Java是单继承继承了Thread就不能继承其他类了扩展性差。实现Runnable接口更灵活也是日常开发中最常用的。需要返回值的时候用Callable它支持泛型返回值和异常抛出。但在实际项目中我几乎不会直接new Thread而是统一用线程池管理因为线程的创建和销毁成本很高频繁创建会导致系统资源耗尽。// 推荐方式通过线程池提交任务 ExecutorService executor Executors.newFixedThreadPool(10); FutureString future executor.submit(() - { // 模拟业务逻辑 Thread.sleep(1000); return 任务执行完成; }); String result future.get(); // 阻塞获取结果3.3 线程间通信的经典范式wait()、notify()、notifyAll()这三个方法定义在Object类上而不是Thread类上这个设计经常被面试官拿来考察。为什么因为锁是对象级别的每个对象都有一把监视器锁所以通信方法自然要定义在Object上。使用这三个方法有个铁律必须在synchronized块或方法中调用否则会抛IllegalMonitorStateException。而且wait()会释放锁sleep()不会释放锁这个区别面试出现频率极高。我写过一个生产者-消费者的例子用wait/notify实现public class ProducerConsumer { private final QueueInteger queue new LinkedList(); private final int MAX_SIZE 10; public synchronized void produce(int item) throws InterruptedException { while (queue.size() MAX_SIZE) { wait(); // 队列满了生产者等待 } queue.offer(item); notifyAll(); // 唤醒消费者 } public synchronized int consume() throws InterruptedException { while (queue.isEmpty()) { wait(); // 队列空了消费者等待 } int item queue.poll(); notifyAll(); // 唤醒生产者 return item; } }注意这里用的是while而不是if来判断条件因为被唤醒后需要重新检查条件是否满足防止虚假唤醒。4. synchronized与Lock体系深度剖析4.1 synchronized的锁升级过程synchronized在JDK 1.6之后做了大量优化引入了偏向锁、轻量级锁、重量级锁的概念。锁升级的过程是不可逆的无锁 - 偏向锁 - 轻量级锁 - 重量级锁。偏向锁的意思是如果只有一个线程访问同步块JVM就把锁偏向这个线程后续该线程进入同步块不需要任何同步操作。一旦有第二个线程来竞争偏向锁就升级为轻量级锁通过CAS自旋来获取锁。如果自旋超过一定次数默认10次可通过-XX:PreBlockSpin调整就升级为重量级锁线程进入阻塞状态。注意JDK 15之后偏向锁默认被禁用了因为它的维护成本高而且在现代多核环境下收益有限。面试时如果提到这个点会让面试官觉得你关注了最新技术动态。4.2 ReentrantLock与synchronized的选型对比两者都是可重入锁但ReentrantLock提供了更多高级功能公平锁/非公平锁可选、可中断获取锁、超时获取锁、条件变量Condition。那什么时候用哪个我的经验是如果只是简单的互斥同步优先用synchronized因为代码简洁而且JVM会自动释放锁不用担心忘记unlock()。如果需要高级功能比如实现一个带超时的锁获取或者需要多个条件队列那就用ReentrantLock。ReentrantLock lock new ReentrantLock(true); // 公平锁 try { if (lock.tryLock(3, TimeUnit.SECONDS)) { // 超时获取锁 // 业务逻辑 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }4.3 AQS原理与手写简易锁AQSAbstractQueuedSynchronizer是ReentrantLock、Semaphore、CountDownLatch等同步器的底层框架。它的核心是一个volatile int state变量和一个FIFO双向队列。线程获取锁失败时会被包装成Node节点加入队列然后通过LockSupport.park()阻塞。我面试时喜欢让候选人手写一个简易的不可重入锁用AQS实现只需要几行代码public class SimpleLock extends AbstractQueuedSynchronizer { Override protected boolean tryAcquire(int arg) { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } Override protected boolean tryRelease(int arg) { setExclusiveOwnerThread(null); setState(0); return true; } public void lock() { acquire(1); } public void unlock() { release(1); } }能写出这个的人对AQS的理解基本就到位了。5. 线程池核心参数与调优实战5.1 七大参数的含义与配置逻辑线程池的七大参数corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。面试官最爱问的是“任务提交后线程池的处理流程”标准答案是核心线程未满则创建核心线程核心线程满了则入队列队列满了则创建非核心线程非核心线程也满了则执行拒绝策略。但光背这个不够你得知道参数怎么配。我的一般原则是CPU密集型任务核心线程数设为CPU核数1IO密集型任务核心线程数设为CPU核数*2。但这只是起点实际值要通过压测来确定。任务类型核心线程数建议队列选择拒绝策略CPU密集型CPU核数1有界队列CallerRunsPolicyIO密集型CPU核数*2有界队列CallerRunsPolicy混合型根据IO/CPU比例有界队列自定义5.2 四种拒绝策略的适用场景AbortPolicy是默认策略直接抛异常CallerRunsPolicy让提交任务的线程自己执行DiscardPolicy默默丢弃DiscardOldestPolicy丢弃队列中最老的任务。我生产环境中最常用的是CallerRunsPolicy因为它能起到背压的作用——当线程池处理不过来时让调用者线程参与处理自然降低了任务提交速度。但要注意如果调用者线程是主线程或者关键路径上的线程可能会导致整个流程阻塞。实操心得千万不要用Executors.newFixedThreadPool()因为它用的是无界队列LinkedBlockingQueue任务堆积过多会导致OOM。阿里开发手册明确禁止使用Executors创建线程池必须手动new ThreadPoolExecutor()。5.3 线程池监控与动态调参线程池跑起来之后你得知道它的运行状态。ThreadPoolExecutor提供了几个关键方法getActiveCount()获取活跃线程数、getQueue().size()获取队列任务数、getCompletedTaskCount()获取已完成任务数。我一般会把这些指标接入监控系统设置告警阈值。比如队列使用率超过80%就告警活跃线程数持续等于最大线程数也告警。更进一步可以用setCorePoolSize()和setMaximumPoolSize()动态调整参数配合配置中心实现不重启应用就能调参。6. 并发容器与原子类应用指南6.1 ConcurrentHashMap的线程安全实现ConcurrentHashMap在JDK 1.7中采用分段锁JDK 1.8之后改为CASsynchronized锁单个桶。为什么改分段锁的并发度受限于段数默认16而锁单个桶的并发度等于桶的数量理论上更高。面试常问“ConcurrentHashMap的size()方法怎么实现的”在JDK 1.8中size()是估算值通过累加每个桶的baseCount和CounterCell数组得到。因为并发环境下精确计数代价太高所以允许一定误差。6.2 CopyOnWriteArrayList的适用场景CopyOnWriteArrayList的核心思想是写时复制每次写操作都复制一个新数组在新数组上修改然后把引用指向新数组。读操作完全无锁所以读性能极高。但写操作代价很大而且数据有延迟。它适合读多写少的场景比如白名单、配置列表。我曾在广告投放系统中用它存储广告位配置每天更新几次但每秒读取上万次效果很好。但如果你用它做实时消息队列那就会出大问题。6.3 原子类与CAS的ABA问题AtomicInteger、AtomicLong、AtomicReference这些原子类底层都是CAS操作。CAS有三个操作数内存值V、预期值A、新值B。当且仅当V等于A时才将V更新为B。ABA问题是CAS的经典缺陷线程1读到值A线程2把值改成B又改回A线程1的CAS操作仍然成功但它不知道值已经被修改过了。解决方案是加版本号Java提供了AtomicStampedReference来解决这个问题。AtomicStampedReferenceInteger ref new AtomicStampedReference(100, 0); int stamp ref.getStamp(); ref.compareAndSet(100, 200, stamp, stamp 1);7. 常见问题与排查技巧实录7.1 死锁的成因与排查方法死锁的四个必要条件互斥、持有并等待、不可剥夺、循环等待。破坏任意一个条件就能避免死锁。实际开发中最容易出问题的是循环等待比如线程A持有锁1等待锁2线程B持有锁2等待锁1。排查死锁用jstack命令它会自动检测并打印死锁信息。我遇到过一次线上死锁jstack输出显示两个线程互相等待对方的锁定位到代码后发现是两个服务互相调用时加锁顺序不一致导致的。解决方案很简单统一加锁顺序。7.2 线程泄漏的识别与解决线程泄漏是指线程池中的线程被长时间占用无法处理新任务。常见原因有任务中有阻塞操作没有设置超时、线程池大小配置不合理、任务提交速度远大于处理速度。排查方法是打印线程堆栈看线程卡在什么地方。如果是卡在SocketInputStream.read()说明网络请求没设超时如果是卡在Object.wait()说明有锁竞争或者条件一直不满足。7.3 并发问题速查表问题现象可能原因排查工具解决方案CPU飙升自旋过多/死循环top jstack减少自旋/检查循环条件响应变慢锁竞争激烈jstack看BLOCKED减小锁粒度/改用读写锁内存溢出队列堆积jmap MAT改用有界队列/增大线程数数据不一致缺少同步代码审查加锁或使用原子类线程泄漏阻塞无超时jstack设置超时时间8. 高并发场景下的设计思路与实战建议8.1 从面试题到生产实践的思维转换面试题往往问的是“是什么”但生产环境要求你回答“怎么做”。比如面试问“volatile的原理”生产环境你要知道“什么时候该用volatile什么时候不该用”。我见过有人给所有共享变量都加volatile结果性能下降严重因为volatile会禁止指令重排序影响CPU优化。正确的做法是只有状态标志位如boolean running才用volatile计数器用原子类复合操作才用锁。这个判断逻辑面试时如果能讲出来面试官会眼前一亮。8.2 高并发系统的分层设计思路高并发不是靠一个技术点解决的而是分层设计的系统工程。我一般从四个层面考虑接入层做限流和负载均衡应用层做无状态设计和缓存服务层做异步和降级数据层做分库分表和读写分离。每一层都有对应的并发技术点。接入层用Nginx的limit_req模块限流应用层用ThreadLocal做线程封闭服务层用CompletableFuture做异步编排数据层用ConcurrentHashMap做本地缓存。这些技术点面试时可能单独考但实际使用时必须组合起来。8.3 并发编程的学习路径建议如果你刚开始学并发我建议按这个顺序先理解线程的基本概念和生命周期再学synchronized和volatile然后深入JMM和happens-before原则接着学AQS和Lock体系最后学线程池和并发容器。每学一个知识点都要写代码验证光看书是学不会的。我当年学AQS的时候把ReentrantLock的源码抄了三遍每一遍都有新的理解。第一遍看懂了大概流程第二遍理解了Node节点的入队出队逻辑第三遍才真正明白LockSupport.park()和unpark()的配合机制。这种底层知识没有捷径可走。最后分享一个我个人的小习惯每次遇到并发Bug我都会把现场信息线程堆栈、内存快照、日志保存下来事后复盘。几年下来积累了上百个案例现在面试时随便挑一个讲都能讲出很多细节。这些实战经验才是面试中最能打动面试官的东西。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑