ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaSE复习核心:从JVM内存、集合底层到并发与NIO的思维框架

JavaSE复习核心:从JVM内存、集合底层到并发与NIO的思维框架 1. 为什么说JavaSE复习的核心不是背语法而是重走一遍内存与对象的主线上次跟一位刚入职半年的同事聊起Java基础他跟我说最近在复习JavaSE问我有没有推荐的资料。我反问他一个问题HashMap在JDK 8里put一个key过程中发生了哪些内存层面的操作他愣了一下然后开始背源码。背得很完整但当我追问为什么要先算hash再定位桶而不是直接用key的hashCode的时候他卡住了。这个场景很典型。很多人复习JavaSE的方式是把语法、关键字、API过一遍但这种复习方式有个很大的问题JavaSE的知识点之间不是孤立的它们有一条共同的线就是JVM如何管理内存、对象如何诞生和消亡、线程如何共享和竞争数据。你只有把这些底层机制想清楚了才能回答为什么而不是停留在是什么。所以我整理这篇复习笔记的时候没有按照教科书的顺序去罗列知识点而是选了五条主线JVM内存与对象生命周期、集合框架的底层结构、并发机制的核心逻辑、IO与NIO的演进、函数式编程的设计意图。每条主线我尽量结合面试题和实际开发中容易踩的坑来讲。学完你能得到的不是一份知识点清单而是一套关于JavaSE的思维框架——以后不管是看框架源码还是排查线上问题都能用上。2. JVM内存布局与对象生命周期从一道面试追问说起2.1 堆、栈、方法区各自在忙什么以及为什么栈上分配更快JVM的内存区域被划分为好几块但真正跟日常开发关系最密切的就是堆、虚拟机栈、方法区JDK 8之后叫元空间。这三者的分工可以类比成一个公司栈是工位方法调用在这个工位上执行每个方法对应一张任务卡堆是公共仓库所有的对象实例都存在那里谁需要就去仓库取方法区是公司制度手册类信息、常量、静态变量都记录在这本手册里。为什么说栈上分配更快因为栈的内存分配只需要移动栈顶指针分配和释放都是跟着方法调用自动完成的不需要垃圾回收器介入。而堆上分配要考虑对象大小、内存碎片、GC扫描等问题。复习的时候要有个概念new出来的对象绝大多数在堆上但在JIT编译的逃逸分析优化下有些对象可能被分配到栈上这就是栈上分配。逃逸分析判断的是对象会不会逃逸出方法——如果对象只在方法内部使用没有返回、没有赋值给外部变量JIT就可能把它分配在栈上随方法结束直接销毁减少GC压力。这个优化很多人不知道但它解释了为什么有时候代码里大量创建短生命周期对象GC压力却没那么大。复习到JVM优化时可以顺带把这个点串起来。2.2 一个对象从new到被回收中间经历了什么我复习时的做法是完整走一遍对象的生命周期每一步都问自己为什么要这样设计。第一步是类加载。对象在诞生之前它的类必须已经被加载、连接、初始化。类加载器把class文件读进内存在方法区生成类元信息。这里有个容易忽略的细节new一个对象时如果类还没有初始化会先触发类的初始化阶段——执行静态代码块、给静态变量赋值。第二步是分配内存。有指针碰撞和空闲列表两种方式具体用哪种取决于堆是否规整堆是否规整又取决于垃圾收集器是否带压缩整理功能。Serial、ParNew这些带压缩整理的收集器用的是指针碰撞CMS这种基于标记清除的收集器用的是空闲列表。分配同时要考虑并发问题——多个线程同时分配对象怎么办CAS加失败重试是一种方案更好的方案是TLABThread Local Allocation Buffer每个线程在堆里预分配一小块私有区域在TLAB里分配不需要加锁。第三步是初始化。内存分配完成后JVM把内存空间初始化为零值然后设置对象头——对象头里存着哈希码、GC分代年龄、锁状态标志等。接下来执行实例变量的显式初始化、构造代码块、构造函数。对象生下来之后它在堆里活着被栈上的引用变量指向。当不再有任何可达引用指向它它就等待被GC回收。判定对象是否存活的标准是可达性分析从GC Roots出发遍历引用链能到达的对象就是存活的否则就是可回收的。GC Roots包括栈帧中的局部变量、静态变量、JNI引用等。复习到这里我建议你用工具实际看一下对象在内存里的样子。JDK自带的jhsdb工具可以查看对象头信息jmap -histo可以看堆里对象分布。光看书不调测永远只能停留在大概了解的层面。3. 集合框架的底层结构ArrayList、HashMap、ConcurrentHashMap复习要点3.1 ArrayList扩容的触发条件与老生常谈的为什么用transient修饰数组集合框架是JavaSE里最常用的部分也是复习时最好抓性能感的地方。先说ArrayList。ArrayList底层是Object[]数组默认容量10每次扩容为原来的1.5倍。扩容的本质是新建一个更大容量的数组把旧数组元素复制过去然后让内部引用指向新数组。这里的关键点在于扩容是一个O(n)操作如果在循环里频繁add会频繁触发扩容复制性能很差。所以当你预判数据量较大时应该用new ArrayList(initialCapacity)指定初始容量。很多人忽略的一个细节是ArrayList里的elementData数组被transient修饰了为什么因为ArrayList实现Serializable接口时自定义了writeObject和readObject方法。序列化的时候只写入数组中有元素的区间而不是整个数组序列化出去。这样一来数组里那些还没被填充的槽位就不会被写到磁盘或网络流里能省不少空间。这个设计思维值得学习——默认序列化是整个对象序列化但当对象内部有冗余字段时自定义序列化是更好的选择。3.2 HashMap的哈希定位、红黑树阈值和扩容机制HashMap是面试里的重头戏也是复习时最值得花时间深挖的类。JDK 8之后HashMap底层是数组加链表加红黑树。哈希定位的过程是先算key的hashCode再把高16位和低16位做异或这样让高位的特征也参与低位的运算降低哈希冲突概率。得到hash之后用(n - 1) hash来取模定位桶的下标前提是数组长度n是2的幂这样位运算能替代取模运算更快。为什么要用红黑树当大量key落在同一个桶里链表长度不断增长时查询复杂度从O(1)退化成O(n)性能断崖式下降。JDK 8的优化是当链表长度达到8且数组长度达到64时链表转成红黑树查询复杂度降到O(log n)。树化的阈值8不是随便定的它基于泊松分布的一个概率统计——在负载因子0.75、随机哈希的假设下链表长度达到8的概率极低约千万分之六。既然概率这么低为什么还要树化因为这是为了防御最坏情况恶意构造大量哈希值相同的key制造哈希碰撞攻击。正常情况下树化很少触发但如果真发生碰撞攻击红黑树能兜底性能。HashMap的扩容也很有讲究。每次扩容为原来的两倍扩容后元素的位置要么在原地要么在原位置加旧容量。判断方法很简单重新计算hash (newCapacity - 1)本质上只需要看新增的那个bit位是0还是1。JDK 8用这个特性做优化把元素的移动拆分成低位链表和高位链表两部分不需要重新计算每个元素的hash位置性能比JDK 7的逐个rehash好很多。复习HashMap时建议亲自看一眼源码重点关注三个方法putVal、resize、treeifyBin。看懂了这三个方法HashMap的百分之七八十就算拿下了。3.3 ConcurrentHashMap的演进从分段锁到CAS加synchronized如果在HashMap前面加一个并发场景问题就变成了多线程同时读写怎么办Hashtable的做法是把整个map加锁所有操作串行化并发度极低。JDK 7的ConcurrentHashMap用分段锁把整个map分成16个Segment每个Segment是一把锁不同Segment的读写互不干扰能把并发度提升16倍。JDK 8彻底废弃了分段锁改用CAS加synchronized插入元素时如果对应桶为空用CAS直接放入不需要加锁如果桶里已经有节点用synchronized锁住这个桶的头节点。粒度从段细化为桶并发度进一步提升了。这个演进过程告诉我们一个重要的设计思路锁的粒度越细并发能力越强但锁粒度太细也会带来额外的开销和复杂度。JDK 8的ConcurrentHashMap在性能和维护性之间找到了很好的平衡点。复习时建议对比一下三个类Hashtable、Collections.synchronizedMap、ConcurrentHashMap看看它们在读多写多的场景下各自的吞吐量表现。我自己实测过在高并发写入场景下ConcurrentHashMap的优势不是一点半点特别是在JDK 8版本基本是首选。4. 并发机制的核心逻辑synchronized、volatile与final的可见性4.1 synchronized锁升级的完整链路偏向锁、轻量级锁、重量级锁Java并发是JavaSE里最容易让人退缩的一块因为概念多、术语多、抽象程度高。我复习时选的策略是抓住一条主线线程之间是如何协调对共享数据的访问的。synchronized是Java里最基础的同步手段。很多人知道它能加锁但不清楚锁的升级过程。在JDK 6之后synchronized并不是一上来就直接用操作系统级的重量级锁而是有一个锁升级的过程。无锁状态下一个线程第一次访问同步代码块时JVM会在对象头里记录这个线程的ID进入偏向锁模式。偏向锁的意思是这个锁会偏向于第一个获得它的线程之后这个线程再次进入时不需要任何CAS操作直接执行开销几乎为零。如果有另一个线程来竞争偏向锁撤销升级为轻量级锁。轻量级锁通过CAS尝试在对象头里记录线程的锁记录指针如果CAS成功线程就拿到了锁如果自旋一定次数还拿不到说明竞争确实激烈升级为重量级锁由操作系统互斥量管理未抢到锁的线程进入阻塞状态。这个升级过程的目的是为了平衡不同竞争场景下的开销低竞争时用偏向锁和轻量级锁高竞争时才动用重量级锁。复习时记住一句话synchronized是基于对象头的锁状态位实现而不是像某些初学者理解的给代码块加了一个标志。4.2 volatile到底解决了什么问题又解决不了什么volatile是另一个高频考点但也是被误解最多的关键字。它的语义有两条保证可见性禁止指令重排序。可见性是怎么保证的volatile变量被修改后会立即写回主内存并且使其他线程工作内存中的缓存行失效。其他线程读取时必须从主内存重新加载由此看到的是最新值。底层是通过在写操作前插入内存屏障实现的防止之前的普通写操作被重排序到volatile写之后也防止volatile读之后的普通读被重排序到volatile读之前。但volatile不保证原子性。经典的count问题就是例子count在字节码层面是读取-修改-写入三步volatile只能保证读取时是最新值但不能保证这三步被原子地执行。两个线程同时读到同一个值各自加1再写回结果就少加了一次。复习到这里很多人会混淆volatile和synchronized的适用场景。我的理解是volatile适合一个线程写、多个线程读的状态标志场景比如控制循环退出的boolean flag而synchronized适合多个线程同时读写同一个共享变量的场景。两者的语义不同不能互相替代。JDK里AtomicInteger这类原子类本质上是用volatile加CAS来同时解决可见性和原子性的也是一个重要的复习线索。4.3 final关键字的安全发布语义final看起来最简单但在并发场景下有独特的作用final修饰的字段构造函数执行完成之后对任何线程都是可见的不需要额外同步。这是因为JMM对final字段有特殊的重排序规则构造函数内对final字段的写入与后续把这个对象赋值给引用变量两个操作之间不能重排序否则可能导致另一个线程看到一个构造到一半的对象其中final字段还是默认值。这个特性在日常开发里其实很有实用价值如果你设计一个不可变对象所有字段都是final的那么这个对象在并发环境下天然是线程安全的可以被多个线程同时安全地引用不需要加锁。Java里的String就是这么设计的。复习时可以把不可变类如何设计作为一个综合练习把final配合private构造器、工厂方法这些知识串起来。5. IO与NIO的演进阻塞、非阻塞与事件驱动的理解5.1 传统IO的三个阻塞点以及为什么说它慢传统IOBIO在复习时很容易被一带而过因为API简单好用。但为什么慢这个问题却值得深入研究。BIO的阻塞发生在三个地方等待连接阻塞等待数据读取阻塞等待数据写入阻塞。以服务端通信为例一个线程accept()等待客户端连接如果没有连接进来线程就卡住不动连接建立后线程又会在read()方法里等待客户端发数据客户端不发数据线程就一直阻塞。这种一个连接占一个线程的模式在连接数量少的时候问题不大但当连接数上万线程数也会跟着上万线程上下文切换开销和内存开销直接爆炸。5.2 NIO的核心组件和零拷贝思想NIONon-blocking IO的出现就是为了解决BIO的阻塞问题。它引入了三个核心概念Channel、Buffer、Selector。Channel是双向的既能读又能写不像BIO的Stream是单向的。Buffer是数据载体读写操作都必须经过Buffer。Selector是整个NIO的事件中心一个线程可以通过Selector同时管理多个Channel只有当某个Channel上有事件可读、可写、可连接发生时Selector才返回对应的SelectionKey线程再去处理。这种一个线程管多个连接的模式就是事件驱动的基础。再往下深挖NIO还有一个重要的底层优化——DirectByteBuffer。它是在堆外内存分配的直接缓冲区在进行系统IO操作时数据可以避免从堆内复制到堆外的中转过程这就是零拷贝思想的体现。FileChannel的transferTo方法甚至在操作系统的帮助下可以直接把内核态的文件数据发送到网络数据在OS内部完成拷贝不经过用户态性能提升非常明显。复习到这里我强烈建议自己动手写一个简单的NIO回声服务器体会一下Selector轮询事件和手动处理Buffer翻转flip的感觉。光看文档是学不会NIO的必须亲手写一遍。6. 函数式编程在JavaSE中的地位Stream流的惰性求值与Optional的初衷6.1 Stream管道流为什么说中间操作是声明式的JDK 8引入的Stream让Java代码的写法发生了很大变化。复习Stream时重点不应该放在API怎么调用上而应该放在管道的执行模型上。Stream管道由三部分组成一个数据源零个或多个中间操作一个终端操作。中间操作包括filter、map、sorted、distinct这些它们的特点是惰性的——调用的时候并不会真正执行只是不断往管道里注册一个操作。只有终端操作比如collect、forEach、reduce被调用时整个管道才会开始执行数据才从数据源一个元素一个元素地流过去经过每个中间操作的加工最终被终端操作消费。这个设计有很多好处。首先是性能中间操作可以通过短路机制提前结束比如limit(5)在取到5个元素后就不会再遍历后面的数据。其次是可以写出声明式的代码你描述我要过滤什么、转换什么、聚合什么而不是写一堆for循环和临时变量。代码的可读性提升很大尤其是对复杂集合操作来说。但Stream也不是万能的。在for循环里可以灵活地break、continueStream里就没那么直接。另外Stream的调试相对麻烦一些因为中间操作都在管道里你没法在每一步都打断点看中间结果。我复习时习惯用peek方法临时打印日志这个方法不作为终端操作而且不影响管道流程调试定位问题很好用。6.2 Optional从设计意图到误用场景Optional是JavaSE里另一个容易误用的类。它的设计初衷是解决NullPointerException问题让可能为空的结果在类型层面显式化提醒调用方处理。但很多人把Optional用成了if判空的语法糖先if (optional.isPresent())再optional.get()写出来的代码比原来的判空还要啰嗦。正确的打开方式是使用它的函数式方法比如map、flatMap、orElse、orElseGet用链式调用的方式处理空值场景。举个例子userService.findById(id)返回一个OptionalUser你想拿到用户的名字如果没有就返回默认值匿名。可以这样写String name userService.findById(id) .map(User::getName) .orElse(匿名);这样写既简洁又不会漏判空。但Note一点Optional本身不应该作为类的字段或者方法的入参因为它没有实现Serializable接口而且把一个可能为空的字段封装成Optional往往是把判空义务延迟到了使用方反而增加了复杂度。这是我复习时反复提醒自己的一个设计原则Optional是用来返回的不是用来传参的。7. 复习JavaSE的路线图与我踩过的坑7.1 我建议的复习顺序与时间分配如果让我重新制定一份JavaSE复习计划我会把时间分成五个板块每个板块侧重点不同顺序也有讲究。先复习JVM内存模型和对象生命周期这是地基。看不懂这部分后面集合、并发会学得云里雾里。再复习集合框架重点把HashMap的源码逻辑啃下来。然后是并发机制建议先理解synchronized和volatile的语义再去看java.util.concurrent包下的类。IO与NIO可以作为相对独立的模块复习但概念上要和JVM内存模型呼应。最后是函数式编程重点是理解Stream的设计模型和Optional的正确用法。每个板块我不建议平均用力。集合和并发值得花最多时间因为这两块是日常开发频率最高、面试问得最深的内容。我自己复习时发现真正拉开水平差距的往往不是那些偏门的API而是对基础机制的理解深度——比如是否清楚HashMap的扩容过程、是否理解synchronized的锁升级。7.2 复习过程中我踩过的两个坑第一个坑是只看不写。我一开始复习JavaSE的时候把《Java核心技术》翻了一遍觉得知识点都串起来了结果去写一个简单的多线程累加程序问题立刻暴露——我对volatile的语义理解停留在表面写出的代码根本没有达到预期效果。复习JavaSE一定要配着写代码进行哪怕是十几行的小demo也比我以为我懂了强得多。第二个坑是不连OSI模型。学习NIO的时候如果只停留在Java API层面你会觉得Selector这东西很神秘。后来我去补了操作系统的IO模型——阻塞IO、非阻塞IO、IO多路复用、信号驱动IO、异步IO这才把NIO的定位看清楚Java的NIO本质上是对操作系统IO多路复用的一种封装理解了OS层面的模型Java的NIO学起来会轻松很多。这也提醒我JavaSE某些知识点是需要跨到计算机基础去理解的不要局限在语言本身。7.3 从JavaSE到框架源码的衔接思路JavaSE学完最直接的检验方式是去看框架源码。看Spring源码时你会在容器初始化过程中看到大量集合操作、泛型、反射、动态代理看Netty源码时你会看到NIO、ByteBuf、线程模型这些JavaSE知识的组合应用。如果JavaSE的地基打得牢看框架源码会从容很多。我自己有一个习惯每复习完一个JavaSE板块就尝试写一篇复盘笔记再用这个板块的知识去解读一个框架类的源码片段。比如复习完集合我就去看了HashMap在MyBatis里的参数处理是怎么用的复习完NIO我去看了Tomcat的NIO模式是怎么处理请求的。这个从基础到框架的衔接方式让JavaSE复习不再脱离实际也让我对框架的设计思路有了更深的体感。最后再分享一个小技巧复习过程中遇到的每一个感觉懂了但说不清楚的概念都值得专门花时间去查、去写、去画图。JavaSE复习真正有价值的地方不在量而在于把那些最底层的机制彻底想明白。这个过程很费时间但它是值得的——因为Java生态再庞大底下垫着的永远是你对这块地基的掌控程度。
返回列表