ARTICLE DETAIL

资讯详情

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

JavaSE进阶:JVM内存模型、集合源码、线程池与NIO实战解析

JavaSE进阶:JVM内存模型、集合源码、线程池与NIO实战解析 很多人以为学完面向对象、集合、异常JavaSE就算通关了但真到写项目或者准备面试的时候才发现卡住自己的往往不是语法而是那些每天都在用却从没深究过的底层机制比如对象到底存在哪里、HashMap为什么有时快有时慢、字符串拼接为什么会被优化、线程池参数到底怎么填。这一篇就是JavaSE系列的第四篇把进阶路上最容易踩坑的几个硬骨头一次性讲透覆盖JVM内存模型、集合底层原理、字符串常量池、多线程与线程池、IO/NIO实战适合已经有Java基础、想补足内功的读者。我不打算罗列文档式的知识点而是用实际写代码时的场景来拆解每个结论都给出判断依据尽量让你读完就能直接用。1. JVM内存模型你的对象到底住在哪1.1 运行时数据区不是一块地皮而是五个功能不同的房间很多人对JVM内存的理解就是“堆和栈”这没错但太粗了。JVM的运行时数据区严格来说分为五块程序计数器、虚拟机栈、本地方法栈、堆、方法区。把这五块放在一起看其实就是在回答一个问题一段Java代码运行的时候每个数据该放哪、由谁管理、什么时候回收。程序计数器是最小的记录当前线程执行到哪一条字节码指令。它不会OOM也是唯一不会发生内存溢出的区域所以一般不需要操心。虚拟机栈对应Java方法的执行每次调用一个方法就会压入一个栈帧栈帧里保存局部变量表、操作数栈、动态链接、方法出口这些信息。局部变量表我后面单独讲它是理解“参数怎么传”的关键。本地方法栈和虚拟机栈结构类似只是服务对象是native方法平时写纯Java代码时基本碰不到。堆和方法区才是大头。堆里存所有的对象实例和数组是GC回收的主战场方法区存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码等。JDK 8之后方法区的实现从永久代换成了元空间最大变化是它不再使用JVM堆内存而是直接使用本地内存默认情况下元空间大小只受机器物理内存限制这就避免了很多因为永久代设置的太小导致的OOM。这里有个很重要的操作习惯线上排查内存问题时先确认是堆溢出、栈溢出还是元空间溢出不同区域的问题对应完全不同的排查路径。堆溢出最常见的表现是java.lang.OutOfMemoryError: Java heap space而栈溢出则是StackOverflowError两个错误名字看着像但一个解决方向是调大-Xmx另一个是检查是不是有无限递归。1.2 从new到GC一个对象的完整一生我把一个对象的生命周期拆开看这样你对内存的管理会更有体感。第一步是类加载。JVM遇到new指令时会先检查这个类是否已经被加载、解析、初始化没有的话就走一遍类加载过程。类加载器Bootstrap、Extension、Application采用双亲委派模型简单说就是先把加载请求向上抛给父类加载器父类加载不了再自己加载。这个机制保证了核心类库不会被用户自定义类覆盖。第二步是分配内存。对象所需内存大小在类加载完成后就能确定接着在堆里划分一块内存。如果堆内存是规整的用“指针碰撞”Bump the Pointer方式分配如果不规整用“空闲列表”Free List方式分配。分配时还要考虑并发问题JVM用CAS加失败重试来保证原子性或者使用TLABThread Local Allocation Buffer每个线程在堆里预先划一小块私有区域分配对象的动作不需要同步这也是为什么大量小对象的创建场景下TLAB能明显提升性能。第三步是初始化。JVM把内存空间清零设置对象头信息哈希码、GC分代年龄、锁标志位、偏向线程ID等然后执行构造函数。之后对象就在堆里活着直到它不再被任何GC Roots引用。GC Roots包括虚拟机栈中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象。JVM通过可达性分析算法从这些根节点往下搜索没被引用到的对象就会被标记为可回收。这里我要说一个容易被忽略的点你写obj null并不代表对象马上被回收它只是让这个对象失去了引用成为可回收对象。真正的回收要等到GC触发而且不同GC策略下回收时机差异非常大。实际开发中与其纠结怎么让对象快点被回收不如先把对象的生命周期控制好别让大对象一直持有不必要引用这比任何GC调优都重要。1.3 栈帧与局部变量表方法执行的真相局部变量表是栈帧里的核心结构它存放方法参数和方法内部定义的局部变量。Java虚拟机的数据类型分为基本类型和引用类型局部变量表以变量槽Slot为单位32位以内的类型占用一个槽long和double占用两个槽。这解释了Java里一个经典问题为什么基本类型传参不会影响原值因为调用方法时传入的是局部变量表里的拷贝值。而引用类型传参传的也是变量槽里的引用地址所以对象内容能被修改但重新赋值不会改变外面的引用指向。方法调用时的“栈深”也是由栈帧数量决定的。递归如果太深每个递归调用都会压入一个栈帧虚拟机的栈容量有限就会出现StackOverflowError。我调试这个问题时有个经验先看代码里递归的退出条件是否可能永远不成立再看递归深度是否在合理范围而不是一上来就调大-Xss。调大栈容量是治标优化递归逻辑比如改成循环或尾递归才是治本。提示查看当前JVM默认栈大小可以用java -XX:PrintFlagsFinal -version | grep ThreadStackSize很多机器默认是1MB左右但实际栈深和每个栈帧的大小有关帧越大能压入的数量越少。2. 集合框架源码级拆解ArrayList和HashMap到底怎么工作的2.1 ArrayList扩容机制不是固定翻倍ArrayList底层的默认初始容量是10每次扩容时新容量是旧容量的1.5倍。JDK 8里的实现是int newCapacity oldCapacity (oldCapacity 1)注意这个右移一位的操作就是除以2所以扩容后是原来的1.5倍。这里我碰到过不少误区有人说ArrayList扩容是两倍其实不对。JDK 6的扩容算法和JDK 8不同JDK 6是(oldCapacity * 3)/2 1而且如果是通过addAll批量添加扩容还会走grow(minCapacity)的逻辑如果一次性添加的元素数量很大会直接扩容到minCapacity而不是按1.5倍慢慢扩。所以如果预先知道数据量最好在构造时指定初始容量比如new ArrayList(1000)这能避免多次扩容的数组拷贝开销。ArrayList还有一个被问烂但很多人答不好的点toArray()返回的是Object数组不能直接强转成String[]而toArray(new String[0])泛型版本才是正道。原因在于toArray只认识运行时类型泛型擦除后你没法让一个Object数组凭空变成String数组。2.2 HashMap哈希、扰动函数与红黑树的配合HashMap在JDK 8里由“数组链表红黑树”组成。put一个键值对时先算key的hashCode然后通过扰动函数处理源码是(h key.hashCode()) ^ (h 16)把高16位和低16位异或目的是让哈希值的高位特性也能参与到底层数组下标的计算。数组下标计算公式是(n - 1) hashn是数组长度。因为n是2的幂所以n-1的二进制全是低位连续的1与hash做与运算等价于取模但比%快得多。这也是HashMap要求容量必须是2的次幂的原因既保证分布均匀又保证位运算可用。链表转红黑树有个前提条件数组长度大于等于64且某个桶的链表长度达到8。如果数组长度没到64即使链表很长也只是扩容不会转树。原因是红黑树节点占用的空间大约是普通节点的两倍在桶数量少时树化反而浪费空间。树退化回链表的情况是扩容导致某个桶的元素被拆分到两个桶里树节点数量低于6就会从树转回链表。这里注意转树的阈值是8退化的阈值是6中间隔着2是为了避免元素在8附近频繁增删导致反复树化和退化。HashMap的扩容更值得关注。默认负载因子0.75意味着当元素数量超过容量乘0.75时就会触发扩容容量翻倍。扩容时会重新计算每个元素的位置这里很多人以为HashMap扩容后所有元素都要重新hash实际上因为容量是2的幂新的下标只有两种情况要么是原来的下标要么是“原下标旧容量”。判断依据是看元素hash的某一位是0还是1这一位如果为0位置不变如果为1位置加上旧容量。我在读源码时看到这里才真正理解为什么HashMap的扩容对链表里的节点可以分成hi和lo两类去处理。2.3 迭代器与fail-fast机制集合里的Iterator遍历时如果在遍历过程中集合结构被修改增删元素迭代器会抛出ConcurrentModificationException。原理是迭代器内部维护了一个modCount字段每次结构改变都会加1而遍历时会校验当前modCount是否等于初始记录值不等就抛异常。这个机制并不是为了保证数据一致性而是“快速失败”让错误尽早暴露。单线程里最常见的触发方式是在for-each循环里直接调用list.remove(element)正确做法是用Iterator.remove()或者用removeIf。多线程环境下如果需要一边遍历一边修改应该使用CopyOnWriteArrayList这类并发容器它每次修改都复制一份底层数组迭代器遍历的是旧数组不会冲突但写开销很大适合读多写少的场景。2.4 集合选型的五个判断维度我在实际选型时会按五个问题走一遍是否允许重复元素是否要求有序是否需要键值对是否要求线程安全读写比例如何。纯列表且读多写少ArrayList因为数组随机访问快。频繁在头部插入删除LinkedList虽然它的节点分散在内存中但双向链表做头尾操作是O(1)。需要去重HashSet如果需要维持插入顺序用LinkedHashSet。需要排序且频繁查询区间TreeSet或TreeMap底层红黑树支持范围查找。线程安全场景优先考虑ConcurrentHashMap而不是Collections.synchronizedMap包装的HashMap因为后者的锁粒度是整个Map并发度太低。3. 字符串那点事String、StringBuilder、StringBuffer别再用错3.1 String的不可变性其实是被设计出来的String类在JDK 8里用一个final char[] value存字符JDK 9之后换成了byte[]加编码标记。类本身是final的字符数组也是final的这保证了String一旦创建内容就不能变。不可变带来三个直接好处线程安全字符串可以被多个线程共享不需要加锁字符串常量池可以复用同一个实例节省内存作为HashMap的key很安全因为key的hashCode不会变化。代价就是每次拼接都会产生新对象频繁修改字符串时性能差。3.2 字符串常量池与intern()的真相直接用双引号创建的字符串会去常量池里找如果池里已经有相同内容的字符串直接返回池中引用而通过new String(abc)创建的字符串会先在堆里创建对象如果常量池里没有“abc”这个字面量会在常量池先创建一个然后堆里再创建一个新对象所以两者不相等。intern()方法的作用是如果常量池中已经有该字符串返回常量池引用如果没有则把当前字符串内容加入常量池并返回引用。JDK 7之后常量池放到了堆里intern的逻辑也调整为如果池里没有直接把当前堆中对象的引用记录到常量池而不再复制一份新的字符串对象所以某些场景下intern能省内存。我对intern的态度是不要为了炫技而用。只有在大量重复字符串且这些字符串确实需要长期存活时比如很多业务系统里的状态码、枚举描述intern才有实际意义。滥用intern还可能因为常量池过大造成性能问题。3.3 高频拼接字符串正确姿势是什么循环里写str s是最差的因为每次加号拼接都会创建一个新的StringBuilder循环10000次就创建10000个中间对象。JDK 8的编译器会把优化成StringBuilder.append但在循环内的拼接每次迭代都会new新的StringBuilder优化等于没有。正确做法是循环体外显式创建StringBuilder循环里只用append方法。但有个细节StringBuilder初始容量默认16如果拼接的内容明显超过16append过程中会触发多次扩容和数组拷贝预先指定容量能避免。所以如果能估算最终字符串长度直接new StringBuilder(expectedLength)性能差距在拼接次数大时非常明显。StringBuffer和StringBuilder的区别只有一个StringBuffer的append方法加了synchronized线程安全但性能差。99%的业务代码里字符串拼接是单线程完成的不需要StringBuffer。3.4 split和replace的隐藏坑String.split(String regex)的参数是正则表达式不是简单分隔符。把.当作分隔符时必须写成split(\\.)因为.在正则里匹配任意字符。同样|也要写成split(\\|)$、^等元字符都要注意。String.replace和replaceAll的区别也一样replace的参数字符串不是正则replaceAll的参数是正则。我见过有人想把所有反斜杠替换成两个反斜杠直接用replaceAll(\\, \\\\)结果抛了PatternSyntaxException因为单个反斜杠在正则里不是合法字符。正确写法是replace(\\, \\\\)或者replaceAll(\\\\, \\\\\\\\)写起来很容易晕我的建议是不需要正则时一律用replace。4. 多线程基础从Thread到线程池一次搞清楚4.1 线程创建方式其实只有一种Java里创建线程的常见写法有两种继承Thread、实现Runnable。但归根到底都是把Runnable实例传给Thread让Thread去执行它的run方法。用Lambda写Runnable是JDK 8之后最省事的方式。Callable和FutureTask的区别在于Callable可以返回值、可以抛异常FutureTask包装后可以交给Thread或线程池执行。线程的真正开销在操作系统层面。创建线程需要分配内核资源、设置栈空间线程切换需要保存和恢复上下文所以每次处理请求都新建线程在高并发下会拖垮系统。这也是为什么线程池是必需品。Executors类提供的几个便捷方法看着好用但坑不少尤其是newFixedThreadPool和newCachedThreadPool一个用无界队列一个最大线程数是Integer.MAX_VALUE一个不小心就OOM。我在实际项目中从来不用Executors的默认方法都是手动new ThreadPoolExecutor。4.2 synchronized和volatile各管什么synchronized解决的是“可见性原子性”问题。可见性由锁的释放和获取保证线程释放锁时会把修改刷新到主内存获取锁时会重新读取主内存的值。原子性则体现在进入同步代码块时锁住对象同一时间只有一个线程能执行。volatile只解决可见性和有序性不解决原子性。它保证每次读取变量都从主内存读每次修改都立即写回主内存同时禁止指令重排序。典型场景是标志位控制循环的停止比如一个线程里while (!flag)另一个线程修改flag不加volatile的话新值可能一直没被循环线程看到。4.3 synchronized锁升级从偏向锁到重量级锁HotSpot对synchronized做了大量优化。锁的状态从低到高依次是无锁、偏向锁、轻量级锁、重量级锁。偏向锁的意思是同一个线程多次进入同步块时不需要每次都做CAS操作去抢锁而是记录线程ID如果当前线程就是持有者直接进入。如果有其他线程竞争偏向锁会撤销升级为轻量级锁。轻量级锁用CAS尝试获取锁如果获取不到说明竞争激烈就会膨胀成重量级锁改用操作系统的互斥量来实现。为什么锁要升级而不是一开始就用重量级锁因为大多数同步代码块在无竞争或者单线程反复执行时CAS和偏向记录的开销远小于线程挂起唤醒的开销。锁升级机制本质是用不同代价应对不同竞争程度。4.4 ThreadLocal线程私有变量背后的内存泄漏风险ThreadLocal为每个线程存储一份独立的变量副本。每个Thread内部有一个ThreadLocalMapkey是ThreadLocal对象本身弱引用value是线程持有的变量。隐含问题来了key是弱引用GC时如果ThreadLocal只被弱引用指向它会被回收但value仍然是强引用。只要线程还存活value就一直无法被回收。线程池里的线程是长期存活的如果不主动remove()就会造成内存泄漏。我用ThreadLocal时的习惯是在finally块里调用remove()每个使用ThreadLocal的地方都必须有这个兜底动作尤其是线程池环境没有例外。4.5 线程池参数填多少才合理ThreadPoolExecutor的核心参数有七个核心线程数、最大线程数、空闲时间、时间单位、阻塞队列、线程工厂、拒绝策略。这几个参数互相制约不能只看某一个。核心线程数的估算如果是CPU密集型通常设为核心数1或者核心数如果是IO密集型可以设为核心数*2甚至更多因为IO等待时CPU还能做其他任务。最大线程数取决于系统能承受的峰值压力队列容量关系到削峰能力。一个常见组合是核心线程数设为服务器CPU核数的两倍最大线程数设为四倍队列用有界LinkedBlockingQueue并设置容量。当线程数达到最大且队列也满时新任务会触发拒绝策略。AbortPolicy是默认策略直接抛RejectedExecutionExceptionCallerRunsPolicy是让提交任务的线程自己执行任务适合不想丢任务的场景。我在开发环境通常用CallerRunsPolicy避免任务无提示丢失但生产环境要看业务能否接受。5. IO与NIO实操对照别再以为FileReader比BufferedReader快5.1 传统IO的分类和误读Java的传统IO按方向分为输入流和输出流按处理单位分为字节流和字符流。字节流的主力是FileInputStream和FileOutputStream字符流的主力是FileReader和FileWriter。字符流底层还是字节流只不过多了编码解码的转换。很多人以为用FileReader直接读文件比FileInputStream快其实不是。FileReader每次read()都会触发一次系统调用效率很低。正确做法是用BufferedReader包裹FileReader或者用BufferedInputStream包裹FileInputStream通过缓冲减少系统调用次数。缓冲区默认大小是8192字节如果读的是大文件可以调大缓冲区。5.2 NIO三件套Buffer、Channel、SelectorNIO的核心是缓冲区、通道和多路复用器。Channel是对传统IO流更底层的抽象可以从Channel读数据到Buffer也可以把Buffer数据写到Channel。Buffer的核心概念有position、limit、capacity读写切换时必须调用flip()切换模式读完要用clear()或compact()重置这点新手最容易忘。Selector允许一个线程监听多个Channel的IO事件这是实现单线程处理大量连接的关键。理解了Selector你就明白为什么Netty能用少量线程维护海量连接。它的本质是操作系统提供的IO多路复用能力比如Linux的epoll。5.3 实操三种方式复制大文件的耗时对照我写了一段对比代码分别用FileInputStream逐字节、BufferedInputStream带缓冲、FileChannel做零拷贝传输来复制一个约500MB的文件// 方式一逐字节复制性能最差 try (FileInputStream in new FileInputStream(source); FileOutputStream out new FileOutputStream(target)) { int b; while ((b in.read()) ! -1) { out.write(b); } } // 方式二带缓冲的流复制 try (BufferedInputStream in new BufferedInputStream(new FileInputStream(source)); BufferedOutputStream out new BufferedOutputStream(new FileOutputStream(target))) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } } // 方式三FileChannel 零拷贝 try (FileChannel inChannel new FileInputStream(source).getChannel(); FileChannel outChannel new FileOutputStream(target).getChannel()) { inChannel.transferTo(0, inChannel.size(), outChannel); }我在同一台机器上测试逐字节方式耗时要慢几十倍缓冲流方式明显改善FileChannel方式最快。原因是transferTo在内核态直接完成数据拷贝减少了用户态和内核态之间的切换和数据往返次数。实际项目里如果只是做文件复制优先用Files.copy()JDK内部也做了优化但如果你想理解NIO的性能优势这个对比值得自己跑一遍。5.4 IO遇坑实录我踩过几个具体的坑写出来供参考。第一个是读文件用了FileReader没指定字符集导致线上文件里的中文乱码。FileReader默认使用平台默认编码Windows上通常是GBKLinux是UTF-8同一个文件换环境就乱码。正确做法是使用InputStreamReader并显式指定Charset。第二个是写入时没有flush程序退出后文件内容为空。BufferedOutputStream和BufferedWriter都有缓冲区写完必须flush或关闭流关闭流会自动flush。第三个是Files.readAllBytes读大文件导致堆内存溢出。这个方法把整个文件加载进内存适合小文件不适合几百MB的大文件大文件要分块读取。6. 常见问题与排查技巧实录错误现象根因解决方案StackOverflowError递归调用出现栈溢出递归深度超过虚拟机栈容量排查递归退出条件或改成循环必要时调整-XssJava heap space内存溢出堆空间不足或存在大对象长期引用dump分析堆定位大对象优化生命周期ConcurrentModificationException遍历集合时报错遍历期间修改集合结构使用Iterator.remove或removeIfRejectedExecutionException线程池拒绝任务队列已满且线程数达到最大调整队列容量、线程数或更换拒绝策略中文乱码文件读写出现乱码字节流按默认编码转换显式指定Charset排查内存问题时我建议先加JVM参数-XX:HeapDumpOnOutOfMemoryError让JVM在内存溢出时自动生成dump文件然后用MAT分析是哪个对象占用了大量堆空间。很多线上OOM其实是集合往静态Map里无限添加数据导致的看到Thread里的ThreadLocalMap和静态字段引用就能定位。线程死锁的排查也很常见。先用jps找到进程ID再用jstack pid查看线程栈如果看到两个线程互相持有对方需要的锁并且在waiting for某个锁基本就是死锁。实际开发中我更推荐用tryLock配合超时时间而不是无条件的lock因为死锁一旦发生定位成本远大于超时重试的成本。最后分享一个实操习惯写JavaSE专题的这四篇我自己也重新翻了不少源码。回头看在生产环境踩过的所有大坑几乎都集中在两个地方一是对JVM内存模型一知半解就动手调参二是集合和线程池“看着能用就行”。有个习惯我想特别推荐给你每次写完一段涉及集合、字符串拼接、IO的代码都停三秒想一想——这段代码在循环里会被执行多少次每次执行产生了多少临时对象如果并发场景下有10个线程同时跑资源会不会被耗尽三个问题想清楚很多性能问题根本不会发生。JavaSE越往深学越像拼图内存模型、集合源码、并发工具、IO模型这些碎片拼在一起你才真正有了“写出来的代码在自己掌控之中”的感觉。
返回列表