做Java开发这些年,被问得最多的不是某个框架的用法,而是“项目跑着跑着变慢了”、“内存莫名飙升”、“接口响应越来越拖”这类性能问题。很多人第一反应是加机器、调JVM参数,但很多时候问题根本不在一台机器能解决的层面。这篇文章把我在实际项目中踩过的、面试里被问过的Java性能优化常见问题,从定位方法到代码陷阱,再到JVM、并发、IO层面的典型坑,全部梳理一遍。内容不一定深奥,但都是能直接落地的经验,适合刚接触性能调优的开发者,也适合想查漏补缺的老手。
在做优化之前,必须先想清楚一个问题:你到底在优化什么。是CPU密集型计算耗时,还是内存占用过高,抑或线程阻塞导致吞吐上不去?不同问题对应完全不同的排查路径,上来就调JVM参数往往南辕北辙。所以这篇文章的排序也按这个逻辑来:先教你怎么定位,再展开具体问题的成因和解法。
1. 性能问题到底出在哪:先学会定位再谈优化
1.1 典型症状与本质原因
性能问题的表象五花八门,但归纳下来就几种:
- CPU使用率飙到90%以上,甚至打满。
- 内存占用持续上涨,最终抛出OutOfMemoryError。
- 接口响应时间从几十毫秒慢慢变成几秒,甚至超时。
- 应用不报错,但吞吐量低下,并发一高就卡死。
这些症状背后对应着不同的原因。CPU飙高通常有两种可能:业务代码里有死循环或大量无谓的计算,比如在循环里反复建对象、做格式转换;也可能是JVM在疯狂执行GC,导致CPU被回收线程占满。内存上涨的原因更直接——对象无法被回收,也就是泄漏,或者堆本身设置得太小不够用。响应变慢的原因最复杂,可能是锁竞争激烈,线程在等待锁上花了大半时间;也可能是数据库查询慢、外部接口调用阻塞,把请求线程拖住了。
这里有个反转认知的点:很多所谓的“Java性能问题”,根因并不在Java代码本身。我见过一个案例,接口本身逻辑很简单,但压测一上来就超时,查了半天发现是下游的HTTP连接池配置过小,线程全都在排队等连接。所以定位问题的时候,不要只盯着代码,整个调用链上的每一个环节都可能成为瓶颈。
1.2 一套实用的排查流程
遇到线上性能问题,我的习惯是按下述顺序排查,能少走很多弯路。
先看监控和日志。如果公司有Prometheus、Grafana这类监控系统,先看CPU、内存、GC频率、线程数等基础指标,确认问题是从哪一刻开始出现的。没有监控就看应用日志,尤其是错误日志和慢请求日志,很多时候根因就藏在某一条异常堆栈里。
然后抓线程栈。线程状态是定位性能问题的利器。用jstack命令可以拿到Java进程的线程快照:
jstack 12345 > thread_dump.txt如果发现大量线程处于BLOCKED或WAITING状态,说明有锁竞争;如果线程一直RUNNABLE但CPU很高,大概率是业务代码在忙等或死循环。线程栈多抓几次,对比一下线程状态的变化,能找到问题线程的规律。
再看GC日志。启动参数里加上-XX:+PrintGCDetails和-XX:+PrintGCDateStamps,把GC日志落盘。通过分析GC频率和停顿时间,能判断内存问题到底是不是GC引起的。如果频繁出现Full GC,并且每次回收后堆内存仍然居高不下,就是内存泄漏的典型信号。
最后上工具。阿里开源的Arthas是目前我用下来最顺手的线上诊断工具,不需要重启进程就能看方法调用耗时、查对象大小、甚至反编译代码。一条命令就能定位到具体是哪个方法在拖慢整体性能。工具千千万,但核心还是那句:先定位再动手,别拿生产环境当试验场。
2. 代码层面的高发陷阱:字符串、对象与集合
2.1 字符串拼接与字符串池
字符串是Java里用得最多也最容易被忽略的对象。最常见的性能陷阱就是循环里用+拼接字符串。老实说,现在Java编译器和JVM针对+做了很多优化,简单拼接几次没问题,但在循环体里反复拼接,每次都会产生新的StringBuilder或String对象,内存和CPU都白花了。
// 不推荐:循环里用+拼字符串 String result = ""; for (int i = 0; i < 10000; i++) { result += i; } // 推荐:显式使用StringBuilder StringBuilder sb = new StringBuilder(10000); for (int i = 0; i < 10000; i++) { sb.append(i); } String result = sb.toString();很多人问为什么不用StringBuffer,因为StringBuffer是线程安全的,内部加了synchronized锁,单线程场景里这个锁完全是多余的。StringBuilder的性能理论上更好,实际压测中差距尤为明显,所以在非并发场景下优先用StringBuilder。
String.intern()也是个隐藏的坑。intern方法会把字符串放进常量池并返回池中的引用,如果同一字符串大量重复出现,用intern确实能节省内存。但现代JVM的字符串去重已经做得不错,滥用intern反而可能因为常量池太大增加GC压力。我的建议是:除非你明确知道字符串有大量重复且数量可控,否则不要碰intern。
2.2 集合容量预设与遍历方式
Java开发者几乎天天都在用集合,可大家最爱犯的错就是不给集合预设容量。ArrayList默认容量是10,数据超过后要扩容,扩容时要把旧数组复制到新数组,数据量一大这个消耗非常可观。HashMap默认容量16,负载因子0.75,当你预计要放1000个元素时,如果不指定容量,中间会经历多次扩容和rehash,整个过程不光耗时,还会产生大量临时对象。
// 给定一个预计元素个数 List<String> list = new ArrayList<>(10000); Map<String, String> map = new HashMap<>(1 << 10); // 1024容量HashMap的容量计算有个细节:如果你预计存入1000个元素,直接new HashMap<>(1000)其实不够,因为默认达到容量的75%就会触发扩容。要预留冗余,容量设为预估元素数除以0.75再加一点缓冲,比如new HashMap<>(1000 * 2)。不过JDK 1.8之后的HashMap在treeify之前会做优化,这个差距没有以前那么大,但作为习惯还是尽量预估容量。
遍历集合时也有一个常见的坑:在循环里反复调用list.size()。虽然size()本身开销极小,但会平添无谓的方法调用;更严重的是在遍历集合时直接remove元素,会触发ConcurrentModificationException。要删除元素,要么用Iterator的remove方法,要么用Java 8的removeIf,既安全又简洁。
3. 内存与GC:从异常日志到回收策略
3.1 内存泄漏的常见形态
内存泄漏是个被说烂又永远避不开的问题。具体到业务代码里,形态就那么几类,但每一类都能让系统在运行几天后陷入瘫痪。
第一类是把对象放进了静态集合。比如用一个静态Map缓存用户信息,往里面塞数据,以为只是缓存,却从不清理。只要应用不重启,这个Map就会越涨越大,最终导致OOM。正确做法是带过期时间的缓存,比如用Caffeine或者Guava Cache。
第二类是未关闭的资源。JDBC连接、文件流、网络连接,用完了没关闭,这些对象持有底层资源,GC回收不了底层资源,随着连接数增多,内存和句柄就会被耗尽。现在虽然有了try-with-resources语法,但老代码里仍然大量存在手工关闭资源的写法,一旦异常分支忘记关闭就泄漏了。我强烈建议所有涉及资源的代码都用try-with-resources:
try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { // 业务处理 }第三类是ThreadLocal的使用不当。ThreadLocal本身设计很精妙,但很多人把用户信息、上下文对象放进去之后从来不remove。在业务线程池里,线程是复用的,ThreadLocal里的值不会被清理,下一个任务就会读到上一个线程残留的数据。轻则数据错乱,重则内存泄漏。解决办法就是每次用完主动remove,或者用装饰器在任务执行完统一清理。
3.2 GC选择与参数调整
JVM的内存模型和GC算法是面试八股文里的常客,但在真实优化场景里,大多数人其实只需要理解几个核心概念。堆内存分为新生代和老年代,新生代又划分为Eden区和两个Survivor区。大部分对象先在Eden区分配,minor GC后存活的对象进入Survivor区,多次GC后仍然存活的对象才进入老年代。老年代满了就会触发Full GC,而Full GC一般是停顿时间最长的。
GC调优的核心目标是减少Full GC次数和单次停顿时间。JDK 8默认垃圾回收器是Parallel Scavenge加Parallel Old,JDK 11及以上可以用G1。G1的设计目标就是平衡吞吐量和停顿时间,通过-XX:MaxGCPauseMillis=200来控制最大暂停时间。如果应用堆内存在4GB以上,我的经验是优先用G1;如果堆小于4GB,默认的Parallel GC其实更合适,强行换G1反而可能增加额外开销。
给我的常用参数示例,大家可以按项目实际情况调整堆大小和GC日志输出:
java -Xms4g -Xmx4g \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=200 \ -XX:+PrintGCDetails -XX:+PrintGCDateStamps \ -Xloggc:/logs/gc.log \ -jar your-app.jar这里必须强调一个误区:把-Xms和-Xmx设成一样是值得推荐的,因为这样可以避免堆内存动态伸缩带来的性能抖动。但堆并不是越大越好,堆大会让GC扫描的时间变长,数据量不大的服务把堆设成8GB反而比2GB更慢。
4. 并发与IO:别让线程阻塞拖慢系统
4.1 锁粒度与并发容器选择
并发性能问题大多出在锁上。Java里的synchronized是JVM层面实现的,ReentrantLock是JDK层面实现的,但其实从性能角度看,在JDK 1.6之后两者差别已经不大了。真正拉开差距的是锁的使用方式,也就是锁的粒度。
一个典型的坏习惯是给大方法加锁。比如一个批量处理的方法,整个方法体全用synchronized包起来,或者用synchronized关键字直接修饰方法。这样会导致所有线程都挤在方法入口,真正能执行的只有一个,并发能力基本归零。正确做法是在方法内部只锁住需要保护的临界区,缩小锁覆盖的代码范围。
锁的粒度并不是越细越好,太细了会导致锁申请次数过多,而且可能出现多个锁之间互相等待,反而触发死锁。实际优化中,如果发现线程大量处于BLOCKED状态,先看一下锁保护的代码量是否过大,再考虑是不是可以用无锁方案替代。
无锁方案里最常用的是并发容器。比如Hashtable和Collections.synchronizedMap,它们的每个方法都加锁,性能很差;而ConcurrentHashMap采用了分段锁和CAS的思路,读操作基本无锁,写操作也只是锁住部分桶,所以高并发下性能远优于Hashtable。另一个常见的是CopyOnWriteArrayList,适合读多写少的场景,写的时候复制整个数组,虽然写开销大,但并发读性能极佳。
AQS(AbstractQueuedSynchronizer)是整个并发包的基石,ReentrantLock、CountDownLatch、Semaphore都基于它实现。理解了AQS,你就能明白什么情况下锁会排队、什么情况下锁能立刻获得,这对排查并发性能问题很有帮助。比如ReentrantLock默认是非公平锁,线程获取锁时先CAS抢一下,抢不到才排队,这样能减少上下文切换。对于性能敏感的代码,非公平锁通常比公平锁更合适,因为公平锁会让每个线程都经历一次睡眠和唤醒,开销大得多。
4.2 IO模型与连接池
Java传统的BIO模型是“一个线程处理一个连接”,当并发连接数增多时线程数会爆炸,而线程本身的创建、切换、销毁都是开销。如果系统里大量使用BIO进行网络通信,性能瓶颈几乎必然出现在线程资源上。NIO和异步IO模型可以让一个线程处理多个连接,通过事件驱动机制避免线程阻塞在IO等待上。
实际项目中,除非是网络通信框架,否则大多数业务应用不太会直接编写NIO代码,而是通过使用高性能框架间接受益。像Netty就实现了Reactor模式,在业界广泛用于网关、RPC服务,性能提升是革命性的。如果你的应用还在用Tomcat 8以下的BIO模式,升级到Tomcat 8.5+的NIO模式,并发能力会立竿见影地提升。
连接池的配置是另一个容易被忽视的性能点。数据库连接创建是非常昂贵的操作,所以用连接池复用连接是常规操作。但连接池大小设置多少合适?一个常见的错误是连接池开得越大性能越好,其实不然。数据库连接数过多,数据库端需要维护大量会话,反而拖慢整体速度。比较经典的公式是:连接数 = ((核心线程数 * 2) + 有效存储磁盘数) / 预计的IO耗时比例,但这个公式偏理论。现实中我一般从小值开始压测,比如20到50个连接,然后逐步增加,观察数据库响应时间和应用吞吐量的拐点。
5. 常见问题速查表与避坑心得
5.1 高频问题速查表
为了节省大家的时间,我把工作里最常遇到的性能问题整理成一张速查表。表里的问题和排查方向都是我实测过的,可以直接拿来当工作手册用。
| 问题现象 | 典型表现 | 大概率原因 | 建议方案 |
|---|---|---|---|
| CPU打满 | GC线程占比高 | 堆内存不足或对象泄漏 | 抓GC日志,调整堆参数,排查泄漏 |
| CPU打满 | 业务线程持续RUNNABLE | 死循环或大循环内重计算 | jstack抓栈定位热点方法,优化算法或加缓存 |
| 接口耗时高 | 线程大量BLOCKED | 锁竞争激烈 | 缩小锁粒度,改用并发容器或无锁方案 |
| 接口耗时高 | 线程大量WAITING | 依赖外部服务响应慢 | 排查下游调用,优化连接池,增加超时时间 |
| 内存缓慢上涨 | Full GC频繁 | 对象无法被回收(泄漏) | 用内存分析工具heap dump,定位持有者 |
| 内存直接OOM | 堆空间溢出 | 堆设置过小或一次性加载过多数据 | 调整-Xmx,批量处理数据,分页查询 |
| 吞吐量上不去 | 线程数不断膨胀 | BIOP导致连接一对一占线程 | 升级NIO模式,或使用Netty等异步框架 |
| 启动特别慢 | 启动过程CPU高 | 大量类加载和反射 | 检查类路径大小,使用AppCDS加速 |
这只是一个基础版本,实际开发中的问题往往交叉出现,比如内存泄漏会引发CPU升高,外部服务变慢会引发线程阻塞进而导致内存堆积。因此排查时不要孤立地看一个指标,要结合上下文判断。
5.2 我的实操体会
第一次真正意义上的性能优化经历是接手一个报表服务,每天的凌晨任务都要跑一个多小时,后来甚至开始OOM。我以为是SQL复杂、数据量太大,结果jstack一抓,发现大量线程卡在把ResultSet里的数据循环塞进List时——那段代码用+拼接了一个超长的SQL语句和日志信息,每一条记录都创建了大量临时字符串,GC根本跟不上。改完StringBuilder,任务耗时降到20分钟,内存也稳了。
还有一次印象深刻的是死锁问题。两个接口调用了彼此的方法,各自持有对方需要的锁,线上流量一上来,两个接口同时卡死,最终导致服务雪崩。那时候才发现,死锁在测试环境很难复现,因为并发量不够触发不了临界条件,一旦上生产就爆出大问题。从那以后我在写锁相关代码时特别注意锁顺序,并且坚持用ReentrantLock的tryLock加超时,而不是无脑synchronized。
另一个实战中的体会是,性能优化一定要和数据量、并发量挂钩。单机几百QPS服务的优化方案,跟几万QPS服务的优化方案完全是两个世界。比如同步转异步、引入消息队列,这种大改造只有在瓶颈真正在同步调用上才值得做,否则引入的消息队列本身就是新的复杂度和故障点。做优化最重要的不是炫技,而是找对支撑点,用最小代价解决最大的问题。
最后再说一个小技巧:在压测阶段就打开GC日志和线程栈,并且定期采集。这样一旦出现问题,你有历史数据可以做对比,可以精确知道“到底是从哪次发布之后开始变慢的”。我见过太多团队在生产环境抓不住问题,就是因为平时没有留痕的意识。性能优化这件事,提前埋好监控点,比任何高级调参都实在。