性能优化是工程实践不是玄学当你盯着VisualVM里那条几乎垂直上升的CPU曲线或者GC日志里频繁出现的Full GC字样时总希望有一种能一键解决所有问题的银弹。可惜Java性能优化从来不是靠某个参数或某个框架就能搞定的。它是一场需要对底层机制、业务场景和代码习惯三者同时下手的手术。本文不聊那些人尽皆知的“避免在循环里创建对象”之类的常识而是深入五个真正能立竿见影、且经常被忽略的实战技巧。它们背后都有JDK源码或HotSpot机制的支撑懂了原理你就能在面对任何慢应用时迅速定位病灶。技巧一用对数据结构比优化代码算法更值钱很多开发者习惯性选用ArrayList和HashMap却不知道在特定场景下它们恰恰是性能杀手。数据结构选错后续所有微优化都是在给系统打强心针。举个例子如果你需要频繁在列表头部插入或删除元素ArrayList的add(0, element)会导致内部数组的System.arraycopy时间复杂度O(n)。此时换成LinkedList虽然插入删除是O(1)但如果你同时还需要随机访问LinkedList的get(index)又是O(n)。那怎么办其实JDK提供了ArrayDeque——它实现了Deque接口底层是循环数组既支持头部插入O(1)不需数据搬迁只需移动头指针又支持随机访问O(1)。同样的道理当key是自然顺序且需要范围查询时TreeMap的红黑树比HashMap的哈希桶更有优势因为TreeMap能提供subMap、headMap等高效操作而HashMap只能遍历全部。HashMap的优化不能只盯着初始容量和负载因子当哈希冲突严重时JDK 8已经把链表转为红黑树来加速查找但树化本身又有内存和计算开销。如果你的key实现了Comparable且数据量不大比如几千个不妨考虑LinkedHashMap的插入顺序特性利用它做LRU缓存性能远超自己手动维护链表HashMap。再谈一个高频误区HashSet和TreeSet的选择。HashSet基于HashMap无序但O(1)TreeSet基于TreeMap有序但O(log n)。如果你的业务需要去重且不关心顺序用HashSet如果需要排序后的去重结果用TreeSet。但千万别在HashSet排序时调用Collections.sort(list)转换成List再排不如直接用TreeSet后者在插入时就已经保持有序。这背后是大量的比较器调用和红黑树旋转但整体复杂度O(n log n)不变而转换成List排序同样O(n log n)还得额外复制一次。用对数据结构有时能直接省掉一个排序步骤。最后提一个冷门但强大的集合ConcurrentLinkedDeque。当你在高并发场景下需要双端队列时这个无锁队列比加锁的LinkedList性能高出几个数量级。它的实现基于Michael Scott算法的变体CAS操作代替了重量级锁。很多性能问题的根源不是算法复杂度而是并发争用选择合适的并发容器往往比优化业务逻辑更高效。技巧二线程池不是越大越好精准计算核心线程数“多线程能加速”是一个朴素但危险的想法。很多应用在加线程后反而变慢原因在于线程上下文切换的开销吞噬了CPU时间。线程池的核心线程数不是随便设一个“CPU核心数2”就行的它取决于任务是CPU密集型还是IO密集型。如果是CPU密集型比如图像处理、复杂计算线程数应该等于CPU核心数或者核心数1防止缺页中断等待。如果是IO密集型比如数据库查询、RPC调用、文件读写线程数可以远大于核心数公式为核心线程数 CPU核心数 (1 等待时间/计算时间)。等待时间通常可以通过压测估算比如一次SQL查询等待10ms而业务逻辑计算只需1ms那么比值是10核心数4的情况下线程数 4 (110) 44。但这里很容易掉入另一个坑超出合理范围后过多的线程会导致操作系统频繁调度每个线程分配到的时间片极短反而没法充分利用IO等待的间隙进行其他计算。更隐蔽的问题是线程池的拒绝策略和队列选择。许多人习惯用LinkedBlockingQueue并设置一个很大的容量比如Integer.MAX_VALUE认为这样就不会拒绝任务。实际上无界队列会让工作线程数永远不会超过核心线程数因为新任务都塞进队列了。如果核心线程数设置得太小比如2而生产速度又很快队列会不断膨胀最终导致OOM或任务延迟指数级上升。正确的做法是使用有界队列并配合合适的拒绝策略。对于计算型应用SynchronousQueue容量0直接交给线程搭配callerRunsPolicy满时由提交线程自己执行往往能起到反压效果。对于IO型应用用一个中等大小的ArrayBlockingQueue比如1000当队列满时触发拒绝策略记录日志或降级而不是默默堆积。另一个经典优化是手动调整allowCoreThreadTimeOut(true)。默认情况下核心线程即使空闲也会一直存活浪费内存。如果你的应用有高峰和低谷设置allowCoreThreadTimeOut并配合ThreadPoolExecutor的setKeepAliveTime可以让空闲的核心线程在超时后回收避免长期占用JVM栈内存和线程对象。每一个存活但空闲的线程都至少占用1MB的栈空间默认-Xss1m10个空闲线程就是10MB100个就是100MB。这种优化在容器化部署且内存受限时尤其关键。最后要警惕线程池嵌套调用。比如一个服务线程池去调用另一个服务的线程池容易产生死锁或线程饥饿。千万不要在线程池的任务里又提交新任务到一个资源有限的线程池除非你非常清楚执行链路和最大深度。否则应该采用CompletableFuture的异步编排或直接使用事件驱动模型来避免嵌套线程池。技巧三锁优化——从重量级锁到无锁的进化路线锁是并发问题最直接的解决方案也是最破坏性能的罪魁祸首。很多人只知道synchronized慢却不知道JDK 6之后它的性能已经大幅提升——引入了偏向锁、轻量级锁、重量级锁的膨胀过程。synchronized在无竞争时只是一个偏向锁只记录线程ID不真正阻塞轻度竞争时升级为轻量级锁使用CAS自旋只有在高竞争时才膨胀为重量级锁导致操作系统挂起线程。所以用synchronized并不必然比ReentrantLock慢关键在于你的锁竞争激烈程度。如果你的临界区很短比如一个简单的countsynchronized的偏向锁或轻量级锁自旋几轮就能成功开销远低于一个Lock对象。但如果临界区较长自旋会白白消耗CPU此时ReentrantLock支持可中断、公平性、多个条件队列等高级功能更合适。一个常见的反模式是在循环内对共享变量使用synchronized。比如通过synchronized加锁来实现CAS语义的计数器其实AtomicLong内部已经用Unsafe的CAS实现而且没有阻塞。更严重的是有些开发者会在for循环内部加锁导致每次迭代都进入临界区锁争用暴增。解决方法很简单将共享变量替换为LongAdder或LongAccumulator。LongAdder内部采用分段Cell类似ConcurrentHashMap的桶将单点的竞争分散到多个Cell上最后求和时再合并。在极高并发下如QPS百万级的计数器LongAdder的性能是AtomicLong的10倍以上。再进阶一点用读写锁代替互斥锁。如果你的业务是读多写少比如配置缓存、商品详情使用ReentrantReadWriteLock可以让多个读线程同时持有读锁只有写线程才阻塞所有其他线程。但要注意ReadWriteLock在读锁内不允许升级为写锁否则会死锁。一个更好的替代是StampedLock它支持乐观读完全不阻塞写操作只有在读取后发现数据被修改通过版本戳校验时才回退到悲观读或写锁。乐观读的性能几乎等同于无锁非常适合高频读取且偶尔更新的场景。最后无锁编程的精髓不是硬用CAS替代所有锁而是找到数据结构的并发安全方式。例如ConcurrentHashMap为什么比Hashtable快因为它用分段锁JDK7或红黑树CASJDK8实现了细粒度并发。你要做的不是自己写一个无锁队列而是学会利用JDK里已经高度优化的并发容器比如ConcurrentLinkedQueue、CopyOnWriteArrayList读多写少场景、ConcurrentSkipListMap有序并发Map。不要重复发明轮子但要理解轮子的原理比如ConcurrentLinkedQueue基于Michael-Scott算法会抛出ABA问题不JDK用了AtomicReference的版本号来解决。你只需要知道当你的缓存或队列能容忍一些瞬时不一致时可以用这些容器代替加锁性能会成倍提升。技巧四JVM参数调优——从“玄学”到精确控制很多人认为JVM参数调优就是设置-Xms和-Xmx最多再加个-XX:UseG1GC。但真正能让性能飞升的参数远不止这些。调优的第一步不是改参数而是先确定衡量指标你需要吞吐量还是低延迟如果是后台批处理任务用-XX:UseParallelGC它能并行收集Full GC暂停时间较长但吞吐量最高如果是用户交互型应用如Web服务器用-XX:UseG1GC或-XX:UseZGC它们能限制最大暂停时间。但如果你的堆内存小于4GBG1GC的Region划分反而增加开销此时-XX:UseConcMarkSweepGCCMS可能更好。不过JDK 9之后CMS已被标记为废弃JDK 14彻底移除所以更好的选择是-XX:MaxGCPauseMillis200配合G1GC通过设置目标暂停时间来让G1自动调整新生代大小和并发线程数。真正容易忽视的是元空间Metaspace和直接内存Direct Memory的配置。元空间默认无限增长受系统内存限制如果ClassLoader很多比如热部署、频繁生成代理类容易引起GC扫描元空间的压力。建议显式设置-XX:MaxMetaspaceSize比如256m或512m防止无限制膨胀导致FGC。直接内存由-XX:MaxDirectMemorySize控制Netty和NIO大量使用它默认等于-Xmx但如果你用NIO读取大文件很可能直接内存先于堆内存耗尽抛出OutOfMemoryError: Direct buffer memory。所以别忘记显式设置直接内存上限并监控它的使用率通过JDK的MemoryMXBean或Netty的计数器。另一个关键参数是线程栈大小-Xss。默认值因操作系统而异Linux下通常是1MB如果你有成百上千个线程栈内存能吃掉大量内存。如果应用没有深度递归调用将-Xss降低到256k甚至128k是安全的。一个连接池有200个线程从1M降到256k立即节省150MB内存。但注意降低栈大小会增加栈溢出的风险需要对递归深度做合理评估。再说一个被很多人忽略的优化大对象直接进入老年代。通过-XX:PretenureSizeThreshold只对Serial和ParNew生效或者-XX:TLABSize和-XX:ResizeTLAB可以控制大对象何时分配。TLABThread Local Allocation Buffer是每个线程在堆里分配的一块私有区域避免同步。如果应用频繁创建大于TLAB区域的大对象通常默认TLAB约100KB这些对象会在Eden区直接分配导致其他线程的TLAB被刷新产生连锁性能问题。通过-XX:PrintTLAB可以观察TLAB使用率。调整-XX:TLABSize到合适值或者启用-XX:ResizeTLAB让JVM动态调整能减少锁争用。最后JVM调优最核心的工具是GC日志。打开-Xloggc:gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintHeapAtGCJDK9以后改用-Xlog:gc然后通过GCeasy或gceasy.io分析文件比任何玄学猜测都管用。例如如果你发现Promotion Failed频繁发生说明老年代碎片化严重可以尝试-XX:ParallelRefProcEnabled加快引用处理或者增大-XX:CMSInitiatingOccupancyFractionCMS下提前触发GC。没有日志分析的调优就是盲人摸象。技巧五善用异步和响应式编程但不是所有地方都适合性能优化的终极武器是——让CPU别闲着。当线程因为IO数据库、网络、磁盘而阻塞时这部分CPU资源就被浪费了。传统同步模型下每个请求对应一个线程当线程在等待IO响应时它无法处理其他请求。一个Tomcat线程池默认200个线程如果每个请求阻塞500msQPS上限就是400。改用异步模型后同一个线程可以在等待IO的同时处理其他请求QPS能轻松提升数倍甚至十倍。但这种提升不是免费的。CompletableFuture是Java 8引入的异步神器但滥用它反而会降低性能。比如你写了一个方法返回CompletableFuture内部却用thenApply串行执行一大堆CPU密集型计算那么这些计算会在ForkJoinPool.commonPool()中执行而这个池的线程数只有CPU核心数减1。一旦计算密集任务堵住commonPool所有其他异步调用都会排队等待。正确的做法是为IO密集型任务单独创建线程池传入CompletableFuture.supplyAsync()的第二个参数让CPU密集型任务由专门的线程池处理commonPool留给短平快的回调。另外CompletableFuture的异常处理一定要用exceptionally或handle否则未捕获的异常会吞没在链式调用中导致future永远不完成进而引起线程池里线程挂死。响应式编程如Spring WebFlux、Reactor走得更远它完全抛弃了线程关联请求的模型使用事件循环和背压Backpressure机制。WebFlux基于Netty使用少量的EventLoop线程通常等于CPU核心数来驱动所有请求。但响应式编程对代码风格有严格侵入你不能再使用阻塞的JDBC、RestTemplate必须换成R2DBC非阻塞数据库驱动或WebClient。如果整个链路中有一个环节是阻塞的整个线程池就被锁死了。所以响应式只有在你完全控制了上下游且业务本身是IO密集型时才有价值。在已有大量阻塞代码的遗留项目中强行引入结果是灾难性。一个更务实的做法是用异步IO框架Netty 少量业务线程替代传统阻塞容器。例如用Netty做网关层转发请求到后端的Spring Boot服务同步。Netty的EventLoop处理网络IO和协议解析业务线程池处理真正的逻辑这实际上是一种“伪异步”但效果很好。关键在于不要让业务线程池的并发数小于后端服务的并发能力否则Netty堆里的Channel会积压大量等待的事件触发高水位警告。最后异步带来的调试和排查难度不可忽视。传统的线程堆栈可以清晰看到每个请求的调用链而异步请求的调用链分散在多个回调中频繁出现“no context”的运行时异常。建议统一引入上下文传播机制如TransmittableThreadLocal并在日志中使用MDC跟踪traceId。性能提升的背后必须是可观测性否则遇到问题会束手无策。结语性能优化没有银弹但有一条金线以上五个技巧每一个背后都有大量源码级别的支撑。但比技巧更重要的是养成在修改代码前先做基准测试的习惯。用JMH微基准测试验证单个数据结构的性能用Async Profiler或Arthas在线剖析CPU火焰图用GCeasy分析GC日志。没有数据支撑的优化只是自我安慰。一个参数改对了能让吞吐量翻倍一个数据结构用对了能让延迟下降一个数量级。把这些实战技巧内化成肌肉记忆你就能在每一次性能压测中精准找到那个最值得优化的点。那些看似玄学的性能问题无非是底层机制的局部表现——当你真正理解JVM的字节码、操作系统的调度、CPU的缓存行你就从“调参侠”进化为“性能工程师”。