ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java核心进阶:JVM调优、并发编程与动态代理实战

Java核心进阶:JVM调优、并发编程与动态代理实战 前段时间有位读者私信我说Java教程跟了三个月Spring Boot也能跑起一个简单的CRUD可一到面试或者接手团队项目碰到“JVM内存怎么排查”“线程池到底配多大”“动态代理和反射在框架里怎么用的”这类问题就发懵。我太懂这个感觉了。Java这个生态特别有意思你刚入门时它看起来是一个“写业务很方便”的语言可一旦工作两三年你会发现真正拉开差距的全是那些藏在对象背后的机制——内存怎么分配、线程怎么调度、框架怎么在运行时替你生成代理对象。这篇文章就围绕我这些年最常被问到、也最值得下功夫的几条核心主线来写JVM内存与调优、并发编程与线程协作、集合框架与动态代理、开发环境与工程构建、面试高频题怎么消化。每部分都会带上实际案例、踩坑记录和可以直接参考的参数或排查思路希望能帮正在学Java、或者在准备面试的人把这条技术主线走通。1. JVM内存与调优先建立程序运行时的“空间感”很多人学Java第一件事是背语法第二件事是写CRUD等到程序莫名其妙OOM或者CPU飙高时才想起来JVM。其实JVM的知识不是救命时才用而是你理解Java程序运行规律的地基。这一节我把运行时数据区、内存分配、调优参数串起来讲配合一次真实的内存泄漏排查过程。1.1 运行时数据区这五个区域各管什么Java程序跑起来之后内存被划分为几个逻辑区域这是面试必背也是排查问题的前提。程序计数器记录当前线程执行到哪一行字节码指令。线程私有没有任何内存溢出风险。Java虚拟机栈每个线程对应一个栈每个方法调用对应一个栈帧。栈帧里存了局部变量表、操作数栈、动态链接、方法出口。递归没写出口就会在这里抛StackOverflowError。本地方法栈给native方法使用的栈一般排查时遇到不多。堆对象实例的主要存储区域也是GC工作最频繁的地方。堆里通常会再分成新生代和老年代新生代里又可以继续细分Eden区和两个Survivor区。方法区元空间存放类元信息、常量、静态变量、JIT编译后的代码。JDK8开始方法区被实现为“元空间”直接使用本地内存不再占用堆内存。如果你把JVM比作一家餐厅堆就是大堂——所有上桌的菜对象都摆在这里虚拟机栈是每个服务员手里的托盘——菜从厨房端到大堂得按顺序放元空间是后厨贴的菜谱——不管今天有没有客人点菜谱永远在墙上。这个概念立住了后面看什么GC日志、内存快照都会顺很多。1.2 一次Full GC和内存泄漏的排查复盘以前接手过一个订单服务白天一到高峰时段接口大面积变慢查看监控发现Full GC频繁得离谱。很多新人这时候会直接拍脑袋加堆内存把-Xmx从2g调到4g结果运行几天后发现盘被撑爆了——其实问题是内存泄漏调大堆只会让崩溃来得更晚。排查步骤我按顺序说用jps -l找到目标服务的PID别用ps grepjps更精准。用jstat -gcutil PID 1000查看GC情况每秒打印一次。如果看到老年代O使用率一路上涨且Full GC次数不断增加基本可以断定有问题。用jmap -dump:formatb,fileheap.hprof PID导出堆快照。注意生产环境谨慎操作最好配合流量低峰期费时费力还可能影响线上。把heap.hprof导入MATEclipse Memory Analyzer分析看Dominator Tree。当时一眼看到大量java.lang.ref.Finalizer对象堆积顺着引用链往下找发现代码里一个线程循环里不断new大对象却没有被及时释放最终全部晋升到老年代。这类问题避免方式也很简单循环体内避免创建重量级对象使用完的流必须在finally里关闭或者直接用try-with-resources不要随便用static集合保存业务数据除非设计上明确它应该常驻内存。1.3 生产环境一套比较稳的JVM参数组合JVM参数不需要背但必须理解为什么这么配。我常用的一套参数如下适用于大多数基于Spring Boot的微服务-Xms4g -Xmx4g -Xmn2g -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/-Xms和-Xmx设置相等避免JVM运行过程中反复申请和释放堆内存引起抖动。-Xmn是新生代大小设置为堆的三分之一到二分之一比较常见。新生代太小会导致短命对象频繁晋升老年代提前Full GC。UseG1GC是JDK9以后的默认垃圾收集器适合多核大内存场景把堆划分为多个Region能够控制最大停顿时间。MaxGCPauseMillis200是G1的软目标不代表一定控制在200毫秒内但可以作为一个调优标尺。加HeapDumpOnOutOfMemoryError绝对是保命配置OOM时自动导出堆快照否则线上出问题你连排查依据都没有。有一个容易忽略的点JDK版本不同参数行为会有差异。比如JDK8的PermGen到JDK8u某个版本后被Metaspace完全替代旧参数-XX:PermSize和-XX:MaxPermSize在JDK11里直接不认。所以每次升JDK建议把启动参数完整过一遍别按住旧文档抄。2. 并发编程核心从“线程等待都完成”到锁与线程池并发是Java后端绕不过去的坎。一开始很多人只知道Thread.start()和synchronized但真正写业务时会发现线程之间的协作、资源池的大小、锁的粒度才是并发代码能不能扛住线上流量的关键。2.1 CountDownLatch优雅地等待多个线程执行完毕搜索词里有一条“java线程等待都完成”对应的场景很常见主线程要启动若干子线程并行处理数据等所有子线程都跑完后再做汇总。最原始的做法是thread.join()但用CountDownLatch更灵活。看一段示例代码import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class CountDownLatchDemo { public static void main(String[] args) throws InterruptedException { int taskCount 5; CountDownLatch latch new CountDownLatch(taskCount); ExecutorService pool Executors.newFixedThreadPool(3); for (int i 0; i taskCount; i) { int taskId i; pool.submit(() - { try { // 模拟每个任务耗时不同 Thread.sleep((taskId 1) * 500L); System.out.println(任务 taskId 执行完毕); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); } }); } latch.await(); // 主线程在这里等待所有任务完成 System.out.println(所有任务执行完成开始汇总); pool.shutdown(); } }这里有个细节很多人会忽略countDown()必须放在finally块里。如果任务执行过程中抛异常latch的计数永远减不到0主线程会一直卡在await()。还有一个点是await()可以传超时参数比如latch.await(10, TimeUnit.SECONDS)防止因为某些线程异常导致主线程无限制等待。CyclicBarrier和CountDownLatch经常被拿来对比。简单说CountDownLatch是“减到0才放行”只可使用一次CyclicBarrier是“凑够人数就发车”可以循环复用还支持重置。2.2 synchronized的锁升级与volatile的边界很多面试题问“synchronized原理”如果你只回答“它给代码块加锁”那基本等于没答。底层它是通过Monitor监视器实现的JDK6以后锁有四种状态无锁、偏向锁、轻量级锁、重量级锁。锁会随着竞争激烈程度升级但不会降级。不过从JDK15开始偏向锁被正式废弃并默认关闭原因是它的维护成本在当代应用面前已经大于收益。所以你解释锁升级时最好提一句“在较新JDK版本中偏向锁已被移除”不然面试官会觉得你的知识停留在三年前。volatile则是另一个高频话题。它保证两个语义可见性和有序性但不保证原子性。最经典的应用是双重检查锁单例public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }为什么这里必须加volatile因为new Singleton()不是原子操作它包含分配内存、初始化对象、把引用赋值给变量三个步骤编译器和CPU可能对它进行重排序。如果不加volatile另一个线程可能拿到一个“引用非空但对象还没初始化完成”的实例程序运行到一半就炸。写并发代码的时候脑子里要绷一根弦共享变量是否需要保证可见性是否需要保证原子性两个都不满足时再去想锁。2.3 线程池参数配置多少线程才算合理线程池几乎是每一家公司在实际项目里都要求用的东西因为直接new Thread有两个问题频繁创建销毁线程开销大线程数量不可控容易把系统打崩。线程池核心参数有七个corePoolSize核心线程数常驻线程。maximumPoolSize最大线程数。keepAliveTime非核心线程的最大空闲存活时间。workQueue任务队列。threadFactory线程工厂一般用来给线程起有意义的名字。handler拒绝策略。核心线程数怎么定没有银弹。如果任务类型是CPU密集推荐CPU核数 1如果是IO密集推荐CPU核数 * 2或者更高。但最靠谱的做法是压测把线程数从2开始往上加观察吞吐量和响应时间找到拐点。拒绝策略有四种AbortPolicy直接抛异常CallerRunsPolicy让调用线程自己执行任务DiscardPolicy直接丢弃DiscardOldestPolicy丢弃队列里最老的任务。线上我一般建议用CallerRunsPolicy它能起到天然限流的作用线程池满时任务回到提交线程执行提交线程忙起来自然就不会再有大量请求涌进来。3. 集合框架与动态代理从八股文到源码真相集合和动态代理是Java面试的重灾区。很多“八股文”背得滚瓜烂熟但问到源码细节、设计意图就露馅。这里我挑几个高频考点展开讲。3.1 HashMap的底层细节为什么是8和64HashMap的底层结构是“数组 链表 红黑树”。当链表长度超过阈值8时链表会转成红黑树但当哈希桶的容量小于64时即使链表到了8也不会立即转树而是先扩容。这里有两个问题面试官爱问为什么是8为什么还有64链表长度达到8的概率极低。源码注释里用了泊松分布计算在负载因子0.75、哈希函数均匀分布的情况下桶中链表长度达到8的概率大约是千万分之六。所以8这个阈值是平衡时间和空间的结果。长度小于8时链表遍历的成本还能接受超过8红黑树的查询优势才体现出来。至于64是因为红黑树的节点比链表节点大如果桶很少却疯狂转树反而浪费内存空间。所以HashMap先扩容扩大桶的数量让元素分散开尽量避免转树。还有一个高频问题HashMap为什么线程不安全。主要表现有两个一是JDK8之前扩容时头插法可能导致链表成环JDK8改用尾插法解决了一部分问题二是多个线程同时put时可能导致数据覆盖。就算你不在面试场景也要记住一个结论多线程环境下用ConcurrentHashMap别抱着“我先用HashMap试试”的心态。3.2 JDK动态代理 vs CGLIBSpring AOP为什么默认选CGLIB动态代理这个词在Java领域里有很具体的含义——在运行时为接口或类生成一个代理对象在调用方法前后插入逻辑。它是Spring AOP、MyBatis Mapper接口、RPC框架的基础设施。JDK动态代理要求目标对象必须实现接口核心是InvocationHandlerimport java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; interface UserService { void sayHello(String name); } class UserServiceImpl implements UserService { Override public void sayHello(String name) { System.out.println(Hello, name); } } class LogHandler implements InvocationHandler { private final Object target; public LogHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(方法执行前打印日志); Object result method.invoke(target, args); System.out.println(方法执行后打印日志); return result; } } public class JdkProxyDemo { public static void main(String[] args) { UserService target new UserServiceImpl(); UserService proxy (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new LogHandler(target) ); proxy.sayHello(Java); } }CGLIB则是通过继承目标类生成子类在子类里重写非final的方法所以不要求目标类实现接口。Spring Boot 2.x以后默认使用CGLIB代理原因很简单很多业务类并没有显式实现接口强行走JDK动态代理反而是限制。实际使用中有个坑如果类被final修饰或者方法是final的CGLIB无法代理。另外Spring AOP生效的前提是Bean方法调用经过代理对象如果类内部this.xxx()调用另一个方法这个调用不会经过代理这也是很多新人配AOP不生效的原因。解决办法是注入自身代理或者拆分到另一个Bean里。3.3 反射的落地场景框架和工具库的“运行时魔法”反射允许程序在运行时获取类的完整结构包括包名、父类、接口、字段、方法、注解等。常见的API有Class.forName()、getDeclaredMethod()、setAccessible(true)等。但反射不是让你在业务代码里频繁使用的它的性能开销远高于直接调用。真正的落地场景是框架层面Spring的依赖注入就是通过反射读取字段上的Autowired动态创建对象并赋值MyBatis通过反射把SQL查询结果映射到JavaBeanJSON序列化框架也依赖反射拿到字段名和值。JDK17以后模块系统默认实施强封装setAccessible(true)访问私有成员可能会抛InaccessibleObjectException。解决方案是启动时加--add-opens参数或者在模块描述符里显式打开包。如果你还在用JDK8这个坑暂时碰不到但一旦上JDK17老框架可能瞬间变“不兼容”这也是为什么每次升JDK版本都要灰度验证所有第三方库的原因。4. 开发环境与工程构建从IDEA写Android到服务器部署环境问题虽然不算高深技术却最容易劝退新人。搜索词里有“idea怎样开发android程序和生成app”“java环境变量配置”“java知道了服务器的公网怎么访问”这几个问题平时被问了无数次我集中讲一遍。4.1 IntelliJ IDEA开发Android程序的环境搭建IntelliJ IDEA不只有Community和Ultimate之分Android开发上推荐使用IDEA Ultimate因为它自带Android支持安装Android SDK之后就能新建Android应用项目。如果你使用Community版需要自己安装Android插件步骤略多。基础环境三步走安装JDK推荐JDK11或JDK17建议直接用官方发行版。安装Android SDK在IDEA的Settings里配置SDK路径。如果SDK不存在IDEA会提示下载你也可以用Android Studio自带的SDK Manager单独下载。新建项目时选择Gradle构建工具语言选Java或Kotlin。配置一个最小可运行的Activity代码并不复杂package com.example.demo; import android.os.Bundle; import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; public class MainActivity extends AppCompatActivity { Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); TextView tv new TextView(this); tv.setText(Hello, Java Android); setContentView(tv); } }这里顺便纠正一个认知误区开发Android不是非要Android Studio不可IDEA本身就能完成开发调试只是Android Studio是Google专门定制的集成了更多Android工具比如布局预览、模拟器管理、Profiler等。日常写点小工具用IDEA没问题但正经做App我还是建议用Android Studio。4.2 从Java项目到APKGradle构建要点Gradle构建Android项目的关键文件是build.gradle。这里最容易踩的坑是依赖版本冲突和构建工具版本不匹配。一个常见的配置片段android { compileSdk 34 defaultConfig { applicationId com.example.demo minSdk 21 targetSdk 34 versionCode 1 versionName 1.0 } } dependencies { implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.9.0 }compileSdk要和已安装的SDK版本一致minSdk决定App最低能跑在哪个Android版本上targetSdk表示针对哪个系统版本做了适配。如果compileSdk高于本地SDK构建时会提示下载对应Platform通常网络慢会卡住提前把网络问题处理好能省很多时间。生成正式APK还需要签名。DEBUG包用的签名是系统默认的只能在调试阶段用上架应用市场必须用正式签名。签名后的APK用一条命令就能打出来./gradlew assembleRelease构建完成后APK在app/build/outputs/apk/release/目录下。4.3 部署后无法公网访问排查思路与解决步骤“java知道了服务器的公网怎么访问”这个问题通常发生在你费劲把Java Web项目部署到云服务器上结果浏览器一访问IP加端口就超时。90%的情况不是Java程序的问题而是服务器的防火墙或安全组把端口挡了。按顺序排查先确认程序在本地跑起来了curl http://localhost:8080能通这是最基础的自测。确认监听地址。Spring Boot项目如果用server.address把地址绑定到了127.0.0.1外部自然访问不到需要改成0.0.0.0。检查云控制台的安全组是否放行了对应端口。这是云服务器最容易被忽略的一层很多云厂商默认只放行80、443、22。检查服务器本机防火墙。CentOS用firewall-cmd --list-ports查看放行列表必要时执行firewall-cmd --add-port8080/tcp --permanent并重载。记得有一次帮朋友排查他折腾了整整半天最后发现是云服务商安全组没加8080端口。所以说排查问题的时候从网络链路的最外层向内逐层查比一上来就看Java日志快得多。顺带提一句数据量小、只是自己用的服务完全没有必要买大带宽服务器1核2G、1M带宽的小实例足够跑Spring Boot单体应用。至于“开发一个App并上架大概要多少钱”这个没有标准答案。简单计算三块成本开发人力如果用外包市面上简单App从几万到几十万不等、云服务器和对象存储等基础设施一个月几十到几百不等、应用市场上架费用安卓各种商店大多是免费的但部分商店有软著等资料要求。自己做个人项目除了服务器费用基本没有硬性门槛。5. 面试高频题库从死记硬背到举一反三再多的技术文章最后都要落到面试和实际产出上。最近几年网上流传着大量Java面试题和“八股文”背题的人不少但真正能通过面试的是能理解题目背后的设计思想的人。我从JVM、并发、集合三个方向各挑几个高频题整理成一张速查表再讲两个如何从“背答案”升级到“讲思路”的例子。5.1 JVM、并发、集合高频题速查分类高频问题核心关键词JVMJVM内存分为哪些区域堆、栈、方法区、程序计数器JVM什么是类加载器双亲委派BootStrap、Ext、App、父委托JVM如何排查OOMjmap、MAT、HeapDump并发synchronized和ReentrantLock的区别锁的实现、公平性、可中断并发volatile能保证原子性吗可见性、有序性、不保证原子性并发线程池参数有哪些corePoolSize、workQueue、handler集合HashMap为什么线程不安全扩容、数据覆盖、链表环JDK8前集合ConcurrentHashMap的锁粒度JDK7分段锁、JDK8 CASsynchronized集合ArrayList和LinkedList区别数组 vs 双向链表、随机访问 vs 插入删除这张表只能用来考前回忆真到了面试别只甩关键词要有展开讲细节的能力。5.2 一个“八股文”题的三层展开法我举一个例子面试官问“HashMap为什么线程不安全”。初级回答是“因为多个线程同时put会丢数据”。这个答案只能拿及格分。有经验的回答建议分三层讲第一层说结论HashMap的put操作不是原子性的多线程并发写的时候会发生覆盖。比如两个线程同时往同一个桶插入元素分别持有不同的Node引用后者可能覆盖前者的值。第二层说细节JDK8以前扩容时头插法可能让链表成环JDK8改成尾插法后链表环问题基本解决但数据覆盖问题依然存在而且size字段的增减也不是线程安全的。第三层说替代方案多线程场景下用ConcurrentHashMap。JDK8以前它按Segment分段加锁每段一把锁支持一定程度的并发JDK8以后放弃了分段锁改为CAS加锁技术锁粒度细化到单个桶并发能力更强。能讲到第三层面试官就能看出你真的看过源码、理解过演进过程而不是死记硬背。5.3 学习路线的建议别跳过“底层机制”关于Java学习路线网上的版本很多但大多数都逃不开这样一条主线Java基础语法 - 集合与泛型 - IO与网络 - 并发编程 - JVM - 常用框架 - 数据库与缓存 - 分布式与微服务。真正想把这套路线走扎实我的建议是每个阶段都至少要有一个“手工项目”。比如学并发不要只读API文档自己写一个生产者消费者模型或者用CompletableFuture实现一个异步编排学JVM自己下载一个MAT对着线上OOM快照分析一次学动态代理自己写一个类似Spring AOP的小工具拦截方法调用并打日志。纸上得来终觉浅Java尤甚。我现在带项目的时候有个习惯安排新人排查线上问题不许第一时间看日志里有没有现成的异样信息先要求他把JVM内存模型、线程池参数、GC机制画出来。很多新人一开始不理解觉得绕远路后来排查问题多了才明白建立“运行机制”层面的直觉比记住某个框架的API有用得多。最后再分享一个小技巧在看任何Java框架源码之前先想清楚“如果让我来设计我会怎么实现”。带着这个答案去读源码你会发现许多设计模式、代理机制、缓存策略其实都在你的认知范围内只是别人把它们组合得更好。Java的核心技术看起来浩如烟海其实主线非常清晰JVM管内存并发管协作集合管存储代理和反射管“运行时增强”。把这四条线吃透不管是写业务、调优、还是应对面试你都会比大多数同龄开发者走得稳。
返回列表