构建一个高可用的Java系统,光会写业务代码远远不够。真正决定系统上限的,往往是那些看不见的东西:JVM如何分配内存、垃圾回收器何时停顿、线程在锁竞争时经历了什么、线程池队列满了之后会发生什么。这些底层机制一旦理解偏差,代码写出来表面正常,压测一上量就原形毕露。这篇文章把JVM和并发包这两块最核心的底子串起来讲,配合实战案例,帮你补齐中高级工程师必须掌握的知识拼图。

一、把JVM内存模型吃透,调优才有方向
很多人调优JVM的第一反应是背参数,其实方向就错了。调优的前提是搞清楚内存是怎么分的。JVM运行时数据区分为堆、虚拟机栈、方法区(JDK 8以后由元空间实现)、本地方法栈和程序计数器。其中堆又分为新生代和老年代,新生代内部再按Eden和两个Survivor区划分,默认比例是8:1:1。
理解这套结构有什么用?举个例子,线上服务频繁Full GC,dump出来的堆文件里全是临时大对象。原因很清楚:对象太大,Eden区装不下,直接进了老年代,老年代很快被填满。这时候调大新生代没用,正确做法是控制单次请求创建的大对象数量,或者适当调整大对象阈值参数-XX:PretenureSizeThreshold,让大对象的分配策略可控。再比如Survivor区太小,导致对象过早晋升老年代,可以通过-XX:SurvivorRatio调整比例。
关于垃圾回收器的选择,JDK 8默认是Parallel GC,吞吐量优先但停顿时间长;JDK 9以后默认G1,它把堆切成多个Region,可以预测停顿时间。如果是低延迟场景,比如交易接口要求P99在50毫秒以内,G1甚至ZGC是更合适的选择。下面是一套常见的G1调优起点参数:
java -Xms4g -Xmx4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-XX:InitiatingHeapOccupancyPercent=45 \
-jar app.jar这套参数的含义是:堆固定4G避免动态扩缩带来的抖动,目标停顿100毫秒,堆占用达到45%就触发并发标记周期。注意MaxGCPauseMillis不是设得越小越好,设得太小会让G1把堆切得更碎,反而降低吞吐。调优的本质是在吞吐和延迟之间找平衡点,没有万能参数,只有结合GC日志持续观察迭代。
二、并发包的核心:从synchronized到AQS
聊并发绕不开锁。早期的synchronized因为性能差被诟病,但JDK 6之后引入了锁升级机制,性能已大幅改善。锁的状态从无锁到偏向锁,再到轻量级锁,最后升级为重量级锁,是一个随竞争加剧逐步膨胀的过程。理解这个机制后你会明白:并发量不高的场景放心用synchronized,代码简洁且JVM会自动优化;只有需要tryLock超时、可中断、读写分离这些高级特性时,才有必要上Lock体系。
JUC包的基石是AbstractQueuedSynchronizer,简称AQS。ReentrantLock、CountDownLatch、Semaphore底层全是它。AQS的核心思想是用一个volatile的int状态变量加一个CLH变体的等待队列,加锁就是CAS修改状态,失败就包装成节点入队阻塞。手写一个简化版的互斥锁,能帮你把原理彻底打通:
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
public class SimpleMutex {
private static class Sync extends AbstractQueuedSynchronizer {
// state=0表示未锁定,1表示已锁定
@Override
protected boolean tryAcquire(int arg) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
@Override
protected boolean tryRelease(int arg) {
if (getState() == 0) {
throw new IllegalMonitorStateException();
}
setExclusiveOwnerThread(null);
setState(0); // 已在独占模式下,无需CAS
return true;
}
}
private final Sync sync = new Sync();
public void lock() { sync.acquire(1); }
public void unlock() { sync.release(1); }
}这段代码虽然只有几十行,却复现了ReentrantLock的核心骨架。实际工作中更常踩坑的是ConcurrentHashMap。比如多线程场景下需要先get判断再put,很多人写成两次调用,以为用了并发容器就万事大吉,结果出现数据覆盖。正确做法是用putIfAbsent或者computeIfAbsent保证原子性。并发容器解决的是单次操作的线程安全,复合操作仍然需要你自己保证原子性,这是并发包使用中最常见的认知盲区。
三、线程池配置:高可用系统的最后一道防线
线程池配置错误引发的线上事故数不胜数。最典型的误区是无脑用Executors.newFixedThreadPool,它内部用的是无界队列LinkedBlockingQueue,任务堆积时队列可以无限增长,最终OOM。而newCachedThreadPool则是最大线程数为Integer.MAX_VALUE,高并发下线程爆炸。阿里Java开发手册明确禁止用Executors创建线程池,原因就在这里。
正确的姿势是手动创建,把每个参数的含义想清楚。线程池的执行顺序是:核心线程未满先建核心线程,满了进队列,队列满了再开非核心线程,达到最大线程数后触发拒绝策略。所以队列的大小直接决定了系统的缓冲能力,而拒绝策略决定了过载时的行为。下面是一个面向IO密集型服务的配置示例:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
16, // 核心线程数:CPU核数 * 2(IO密集型)
32, // 最大线程数
60, TimeUnit.SECONDS, // 非核心线程空闲回收时间
new ArrayBlockingQueue<>(500), // 有界队列,防止任务无限堆积
new ThreadFactoryBuilder().setNameFormat("biz-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:调用者执行,起到反压作用
);这里选CallerRunsPolicy是有讲究的。CallerRunsPolicy在被拒绝时会由提交任务的线程自己执行,相当于给上游一个天然的反压信号:下游处理不过来,上游自然被阻塞减速。相比之下AbortPolicy直接抛异常,DiscardPolicy静默丢任务,在订单、支付这类不能丢数据的场景都是雷。另外,线程数配置的经验公式是:CPU密集型取核数加1,IO密集型取核数乘以2起步,再通过压测微调。
最后强调监控的重要性。线程池创建完不代表工作结束,要通过getActiveCount、getQueue().size()等指标接入监控告警,队列使用率超过80%就该报警。高可用从来不是配置出来的,而是在一次次线上波动中持续观察、持续打磨出来的。把JVM的内存与GC机制、并发包的锁与队列原理、线程池的容量规划这三块吃透,你面对再复杂的线上问题,都能有一套清晰的排查路径和优化手段。