使用 Grafana 和 Prometheus 搭建监控
本文介绍如何通过 Dockers Compose
安装 Grafana
和 Prometheus
在局域网中配合各类 exporter
为主机和诸多内部服务搭建监控。
本文介绍如何通过 Dockers Compose
安装 Grafana
和 Prometheus
在局域网中配合各类 exporter
为主机和诸多内部服务搭建监控。
在分布式应用中,并发访问资源需要谨慎考虑。比如读取和修改保存并不是一个原子操作,在并发时,就可能发生修改的结果被覆盖的问题。
很多人都了解在必要的时候需要使用分布式锁来限制程序的并发执行,但是在具体的细节上,往往并不正确。
本质上要实现的目标就是在 Redis 中占坑,告诉后来者资源已经被锁定,放弃或者稍后重试。Redis 原生支持 set if not exists 的语义。
1 | > setnx lock:user1 true |
如果在处理过程中,程序出现异常,将导致 del 指令没有执行成功。锁无法释放,其他线程将无法再获取锁。
对 key 设置过期时间,如果在处理过程中,程序出现异常,导致 del 指令没有执行成功,设置的过期时间一到,key 将自动被删除,锁也就等于被释放了。
1 | > setnx lock:user1 true |
事实上,上述措施并没有彻底解决问题。如果在设置 key 的超时时间之前,程序出现异常,一切仍旧会发生。
本质原因是 setnx 和 expire 两个指令不是一个原子操作。那么是否可以使用 Redis 的事务解决呢?不行。因为 expire 依赖于 setnx 的执行结果,如果 setnx 没有成功,expire 就不应该执行。
如果 setnx 和 expire 可以用一个原子指令实现就好了。
在 Redis 2.8 版本中,Redis 的作者加入 set 指令扩展参数,允许 setnx 和 expire 组合成一个原子指令。
1 | > set lock:user1 true ex 5 nx |
除了使用原生的指令外,还可以使用 Lua 脚本,将多个 Redis 指令组合成一个原子指令。
1 | if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then |
基于 Redis 的分布式锁还会面临超时问题。如果在加锁和释放之间的处理逻辑过于耗时,以至于超出了 key 的过期时间,锁将在处理结束前被释放,就可能发生问题。
如果第一个线程因为处理逻辑过于耗时导致在处理结束前锁已经被释放,其他线程将可以提前获得锁,临界区的代码将不能保证严格串行执行。
如果在第二个线程获得锁后,第一个线程刚好处理逻辑结束去释放锁,将导致第二个线程的锁提前被释放,引发连锁问题。
与其说是改进,不如说是注意事项。如果真的出现问题,造成的数据错误可能需要人工介入解决。
如果真的存在这样的业务场景,应考虑使用其他解决方案加以优化。
为 Redis 的 key 设置过期时间,其实是为了解决死锁问题而做出的兜底措施。可以为获得的锁设置定时任务定期地为锁续期,以避免锁被提前释放。
1 | private void scheduleRenewal() { |
但是这个方式仍然不能避免解锁失败时的其他线程的等待时间。
可以将 set 指令的 value 参数设置为一个随机数,释放锁时先匹配持有的 tag 是否和 value 一致,如果一致再删除 key,以此避免锁被其他线程错误释放。
1 | tag = random.nextint() |
但是注意,Redis 并没有提供语义为 delete if equals 的原子指令,这样的话问题并不能被彻底解决。如果在第一个线程判断 tag 是否和 value 相等之后,第二个线程刚好获得了锁,然后第一个线程因为匹配成功执行删除 key 操作,仍然将导致第二个线程获得的锁被第一个线程错误释放。
1 | if redis.call("get", KEYS[1]) == ARGV[1] then |
可重入性是指线程在已经持有锁的情况下再次请求加锁,如果一个锁支持同一个线程多次加锁,那么就称这个锁是可重入的,类似 Java 的 ReentrantLock。
Redis 分布式锁如果要支持可重入,可以使用线程的 ThreadLocal 变量存储当前持有的锁计数。但是在多次获得锁后,过期时间并没有得到延长,后续获得锁后持有锁的时间其实比设置的时间更短。
1 | private ThreadLocal<Integer> lockCount = ThreadLocal.withInitial(() -> 0); |
还可以使用 Redis 的 hash 数据结构实现锁计数,支持重新获取锁后重置过期时间。
1 | if (redis.call('exists', KEYS[1]) == 0) then |
书的作者不推荐使用可重入锁,他提出可重入锁会加重客户端的复杂度,如果在编写代码时注意在逻辑结构上进行调整,完全可以避免使用可重入锁。
1 | public class ByteCodeTest_2 { |
在编译期间就可计算得到操作数栈和本地变量表的大小。
1 | stack=2, locals=4, args_size=1 |
Slot,即槽位,可理解为索引。
1 | Start Length Slot Name Signature |
1 | #3 = Integer 32768 |
1 | 0: bipush 10 |
1 | public class ByteCodeTest_3 { |
1 | 0: bipush 10 |
1 | public class ByteCodeTest_4 { |
1 | 0: iconst_0 |
<cond>
,一个 int 和 0 的比较成立时进入分支,跳转到指定行号。<cond>
,一个 int 和 0 的比较成立时进入分支,跳转到指定行号。每一次重新阅读,都有新的收获,也将过去这段时间以来一些新的零散的知识点串联在一起。
沿着周志明老师的行文脉络,了解问题发生的背景,当时的人如何思考,提出了哪些方案,各自有什么优缺点,附带产生的问题如何解决,理论研究如何应用到工程实践中,就像真实地经历一段研发历史。这让人对垃圾收集的认识不再停留在记忆上,而是深入到理解中,相关的知识点不再是空中楼阁,无根之水,而是从一些事实基础和问题自然延申出来。
尽管在更底层的实现上仍然缺乏认识和想象力,以至于在一些细节上还是疑惑重重,但是仍然有豁然开朗的感觉呢。比如以前看不同垃圾收集器的过程示意图如鸡肋,如今看其中的停顿和并发,只觉充满智慧。
垃圾收集(Garbage Collection,简称 GC)需要考虑什么?
为什么要去了解垃圾收集和内存分配?
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化技术”实施必要的监控和调节。
在 Java 中,垃圾收集需要关注哪些内存区域?
程序计数器、虚拟机栈和本地方法栈,随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出有条不紊地执行着入栈和出栈操作,每个栈帧中分配多少内存可以认为是编译期可知的,因此这几个区域地内存分配和回收具备确定性。
但是 Java 堆和方法区这两个区域则有显著的不确定性,只有运行期间,我们才知道程序会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。
哪些对象是还存活着,哪些已经死亡?
对象死亡即不可能再被任何途径使用。其实曾经的我会怀疑,遗落在内存中的对象,真的没有办法“魔法般地”获取其引用地址吗?引用变量的值不就是 64 位的数字吗?
优点:
缺点:
提及引用计数算法,人们好像认定它无法应对循环引用因而被抛弃。虽说 Java 虚拟机中没有选用它,但是在其他计算机领域有所运用。循环引用也并非它绕不过去的难题,事实上,跨代引用问题中,老年代引用新生代形成的引用链不是也可能是一个尚未回收的孤岛吗?
那么可作为 GC Roots 的对象有哪些呢?
固定的 GC Roots,主要是在全局性引用和执行上下文中:
临时性的GC Roots:
除了固定的 GC Roots 集合外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入。
比如,当针对新生代发起垃圾收集时,如果老年代对象引用了它,那么被引用的对象就不应该被回收,尽管老年代对象可能已经不可达。为此,老年代对象需要临时性加入 GC Roots 集合。
当然,为了避免将所有老年代对象加入 GC Roots 集合这样一看就很不合理的操作,会做一些优化处理。
对于判断对象是否存活而言,“引用”的重要性不言而喻。但是如果对象只有“被引用”和“未被引用”两种状态,对于描述一些“内存足够就保留,内存不足就抛弃”的对象就显得无能为力。
缓存系统就是这样的一个典型应用场景。当内存充足时,就保留作为缓存;当内存不足时,就抛弃腾出空间给其他资源。
曾经有一位热衷实践技术的同事就和我介绍了他在项目中使用弱引用实现的缓存模块,当时我还不太理解他为何这样做。事实上,享受自动垃圾收集的我并不能在一开始就敏锐地把握到对象在应用程序中的创建、存活和消亡过程。
当然我们并不推荐自己实现基于 JVM 的缓存系统,事实上他之所以提及,正是因为出了 bug。
虚引用的一个经典应用是是 ByteBuffer 对象被回收时自动释放直接内存。
1 | public class ReferenceTest_3 { |
在测试中,minor GC 并没有回收掉全部的只被弱引用关联的对象,full GC 才全部回收掉,我一度以为关于弱引用的表述不正确。后来进一步测试发现,是因为部分对象直接分配在老年代。因此更准确的表述是,每一次 GC 都会回收所在发生区域里只被弱引用关联的对象。
这是一个有趣的经验,让我对部分垃圾收集中的“部分”二字有更深刻的体会,原来非收集区域的对象真的对发生在其他区域的垃圾收集无感。
了解为什么扩充引用的概念,让人对引用的分类豁然开朗。我的脑海里情不自禁冒出了不太恰当的比喻:一个城市里的公民被区分了等级,一等公民(强)永远不会被强行驱逐;二等公民(软)在城市资源紧张时会被强行驱逐;三等公民(弱)被认为影响市容市貌,一旦有整顿就会被强行驱逐;一等公民里有一些需要被监视,一旦离开,会触发一个事件。
有趣的知识点,无趣的面试考点。
方法区的垃圾收集主要回收两部分:
如何判定一个常量是否废弃?
没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。
如果这时发生垃圾回收,而且垃圾收集器判断确实有必要,才会将“java”常量清理出常量池。
“虚拟机中也没有其他地方引用这个字面量”怎么理解?
如何判定一个类型是否可卸载?
Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是
和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制,还可以使用 -verbose:class 以及 -XX:+TraceClassLoading、-XX:+TraceClassUnLoading 查看类加载和卸载信息。
条件二如此苛刻,系统类加载器不会被回收,是否意味着正常的应用程序,类一旦加载就不会卸载?
“无法在任何地方通过反射访问该类的方法”是否多余,Method 对象不是引用了 Class 对象吗?
Class 对象没有被引用时,会被回收吗?
卸载类是指回收 Class 对象加上清理方法区中的类的信息(怎么样的存储结构呢)吗?
分类:
分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
这两个分代假说共同奠定了多款常用垃圾收集器的一致的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中存储。
正因为有了区域划分,垃圾收集器才可以每次只回收一个或某些部分的区域,因而才有了“Minor GC”、“Major GC”和“Full GC”等回收类型划分;针对不同区域安排与里面存储对象存亡特征相匹配的垃圾收集算法,因而才发展出“标记-复制”、“标记-清除”和“标记-整理”等垃圾收集算法。
了解分代收集理论,分代收集算法更显得有理有据。
一般把 Java 堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。
针对不同分代的垃圾收集分类:
区域划分引起另一个问题,跨代引用。这个问题在前文的 GC Roots 选择时也提到过。
根据前两条假说可逻辑推理得出隐含推论:存在相互引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。
依据这条假说,我们不应再为少量的跨代引用而去扫描整个老年代。那么怎么处理跨代引用呢?在后面 HotSpot 的实现细节中我们再提。
在 1960 年由 Lisp 之父 John McCarthy 提出,分为标记和清除两个阶段。
缺点:
为解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969 年 Fenichel 提出“半区复制”(Semispace Copying)。将内存划分为大小相等的两块,每次只使用一块,用完的时候,复制存活的对象到另一块。
优点:
缺点:
适用场景:
怎么减少空间的浪费呢?
IBM 公司研究表明新生代中 98% 的对象熬不过第一轮收集。在 1989 年,Andrew Appel 针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel 式回收”。具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。
HotSpot 虚拟机默认 Eden:Suvivor 为 8:1。
在测试中,尽管 survivor 有空间,仍然只在 Eden 空间进行分配。
如果 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时怎么办?
罕见情况的“逃生门”——使用其他区域(通常是老年代)进行分配担保(Handle Promotion)。
标记-复制算法面对对象存活率较高的情况,效率会降低;更关键的是,它需要额外空间进行分配担保。
针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了另外一种有针对性的“标记-整理”算法,让所有存活对象都向内存空间一端移动。
移动和复制的开销有什么差距吗?撇开分配担保问题,大量复制和大量移动是类似的把?
移动对象的弊端
不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。在这点上的区别分别演变出两种发展方向,低延迟和高吞吐量。
书中提到的内存分配器和内存访问器解决的是在碎片化的内存空间中进行内存分配,还是可以将大对象分散地存储到碎片化的空间中呢?还提到硬盘存储大文件不要求物理连续的磁盘空间以及内存访问环节的额外负担,应该是指一种分散存储的实现方案吧?
对于 CMS 的“和稀泥”解决方案,暂时容忍内存碎片,直到影响对象分配时再采用标记-整理算法收集一次,如果抛开备用的 Serial Old 单线程的效率问题不谈,除非在碎片化的内存空间中分配和访问内存在效率上低于在整理后的内存空间中,要不然“和稀泥”这种懒惰式的处理方案理论上效率更高吧?
对象浩如烟海,怎么实现高效查找根节点的呢?
迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的。这是因为根节点枚举如果不在一个一致性快照中进行,准确性无法保证。
Exact VM 类型的虚拟机使用的是准确式垃圾回收,当用户线程停顿后,不需要一个不漏地检查完所有执行上下文和全局的引用位置,就有办法直接得到哪些地方存放着对象引用。HotSpot 的具体解决方案是使用一组称为 OopMap(Ordinary Object Pointer Map) 的数据结构。
看书时,在这个地方其实有很多困惑。对于 OopMap,就像知道了一个不太懂的东西,了解了它能做什么,最明确的是,在即时编译中,会在安全点位置生成 OopMap。
首先,我困惑的是,非准确式垃圾回收是什么东西,要找到对象引用得怎么做?
书中提到,准确式内存管理是指虚拟机可以知道内存中某个位置的数据具体是什么类型。比如内存中有一个 32 位的整数 123456,虚拟机将有能力分辨出它到底是一个指向了 123456 的内存地址的引用类型还是一个数值为 123456 的整数。
Exact VM 抛弃掉以前 Classic VM 基于句柄(Handle)的对象查找方式,因为在垃圾收集后对象可能被移动,如果地址改变(123456->654321),在没有明确信息表明内存中哪些数值式引用类型的情况下,虚拟机肯定不能把所有123456的值改为654321,所以要使用句柄来保持引用值得稳定。
看到这个举例之后容易理解多了,可能是没有手动管理内存的经验,对这方面体会不深刻。
以栈为例,栈帧里装有 int、double 等类型的数值,也有引用类型变量的地址,这些值在保守式 GC 看无法直接分辨是数值还是引用。但保守式 GC 其实还是有一定的分辨能力:
但是根据这些规则进行检测还是不够的,比如某一个 int 的值刚好指向某个对象的起始地址,就恰好满足上述的所有条件,这样就可能造成该对象被错误识别为存活对象,这个现象称为对堆的压迫。
保守式 GC 的“保守”二字体现在它将可疑的根看作是指针进行保守地处理。
死亡的对象被错误保留下来,挤占了堆空间,称其为“压迫”还听形象的。这个“保守”在理解以后才不感觉违和。
方法区的类静态属性和类常量也是根据类型信息里的 OopMap 查找的吗?
为什么引入安全点,它解决了什么问题?
在 OopMap 的协助下,HotSpot 可以快速准确地完成 GC Roots。但是非常多的指令可能导致 OopMap 的内容发生变化。
书中“可能导致引用关系变化……的指令非常多”这句话让人困惑,OopMap 不是为了定位哪里是引用而非引用指向哪吧?如果只看“导致 OopMap 内容变化的指令非常多”更易理解。
在哪里生成 OopMap 呢?
既然指令会导致 OopMap 内容发生变化,最简单粗暴的就是为每一条指令生成 OopMap,但是这样会空间成本会急剧上升。
HotSpot 只在特定的位置生成 OopMap,这些位置被称为“安全点”(Safepoint)。
安全点的设定决定了并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是要求必须到达安全点才能够暂停。
安全点怎么选择呢?
安全点的位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的:
有没有权威的标准描述啊。
如何在垃圾收集发生时,让所有线程(不包括 JNI 调用的线程)都跑到最近的安全点,然后停顿下来呢?
这个问题和如何通知多线程一起做一件事很像,设置标志位,再让线程运行过程中轮询。
什么时候进行轮询?
轮询标志的地方与安全点是重合的,另外还要加上创建对象和其他需要在堆上分配内存的地方,这样可以检查是否即将发生垃圾收集,避免没有足够内存分配给新对象。
仔细一想,与安全点重合似乎是理所当然,额外选取的地方也有恰当的理由。
轮询在印象里都意味着额外开销,虚拟机如何应对?
HotSpot 使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。
很遗憾,看懂又不懂,总之,轮询操作优化得很高效。
为什么引入安全区域,它解决了什么问题?
安全点机制保证了程序执行时,在不太长的时间内,就会遇到可进入垃圾收集过程的安全点,但是如果程序“不执行”的时候,比如线程处于 Sleep 或者 Blocked 时,就无法通过轮询获知中断标识。对于这种情况,引入了安全区域(Safe Region)来解决。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化。安全区域可以看作是扩展拉伸了的安全点。
不去管处于安全区域的线程的意思是什么,不是线程主动轮询是否中断的标志吗?这个标志不是全局共享的吗,是虚拟机为各个线程单独设置的?
为什么引入记忆集,它解决了什么问题?
为了解决对象跨代引用所带来的问题,比如在标记新生代时,避免将整个老年代的对象都加入 GC Roots 进行扫描。
这个问题起初让我很迷惑,直到我认识到“老年代里有一些已经不可达但还没回收的对象,它们可能引用了新生代里的对象”这个现象,我在想我可能理解了跨代问题。但是此时,我又迟疑了,如果引用新生代对象的老年代对象已经不可达,回收掉就好了,如果老年代对象可达,那么从固定的 GC Roots 一定可以找到老年代对象,再找到新生代对象。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
如果不考虑效率和成本,最简单的方法可以用非收集区域中所有含跨代引用的对象数组来实现。
设计者通常选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,比如:
卡表(Card Table)是记忆集的一种具体实现。
卡表最简单的形式可以只是一个字节数组,HotSpot 虚拟机也是这样做的。
1 | CARD_TABLE[this address >> 9] = 0; |
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个或更多对象的字段存在跨代引用指针,就将卡表的数组元素的值标识为 1,成该元素变脏(Dirty)。
这是否以为着卡表长度为 (老年代容量 / 512),这内存占用还是不低欸。
区域内怎么判断出对象的开始和结束位置呢?虚拟机可以通过一个地址,知道该地址是一个对象的起始地址吗?
卡表元素如何维护?它们何时变脏,谁来把它们变脏?
卡表元素何时变脏的答案是明确的——有其他分代区域中对象引用本区域对象时,其对应的卡表元素就应该变脏。
问题是如何在对象赋值的那一刻去更新维护卡表呢?
在解释执行字节码的情况中,虚拟机有充分的介入空间;但是在编译执行的场景中,即时编译后的代码已经是纯粹的机器指令流了。
HotSpot 虚拟机是通过写屏障(Write Barrier)技术维护卡表状态,在机器码层面介入每一个赋值操作。
又是一个看懂又不懂的事情,很多资料里尝试用伪代码表达,但是我很想知道它是怎么实现在机器码层面介入每一个赋值操作,是在 JVM 源码中用 C++ 实现的吗?是在解释器和 JIT 编译器共同支持下,在机器码层面加入了代码片段吗?
总之,理解为引用字段赋值的 AOP 切面吧。
1 | void oop_field_store(oop* field, oop new_value) { |
这应该只是为了表达思路的伪代码吧?
应用写屏障后,每次只要对引用更新,都会产生额外的开销,但是和 Minor GC 时扫描整个老年代的代价相比还是低很多的。
除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。
现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响而导致性能降低。
假设 64 个卡表元素共享一个缓存行,对应的卡页总内存为 32 KB,如果不同线程更新的对象都在这 32 KB 的内存区域内,就会导致更新卡表时发生伪共享问题。
一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有未被标记才标记为变脏。
在 JDK 7 后,新增 -XX:+UseCondCardMark 参数决定是否开启卡表更新的条件判断。开启后增加一次额外的判断开销,但能够避免伪共享问题。
从 GC Roots 再继续向下遍历对象图,停顿时间必然与堆容量成正比。如果能够削减这部分的停顿时间,对于所有使用追踪式垃圾收集算法的收集器而言,“标记”阶段都能收益匪浅。
为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?
三色标记(Tri-color Marking),扫描过程就想灰色波峰从黑向白推进。
白色:表示对象尚未被垃圾收集器访问到。
黑色:表示对象已经被垃圾收集器访问到,且这个对象的所有引用都已经扫描过了。
灰色:表示对象已经被垃圾收集器访问到,但这个对象上至少有一个引用还没有被扫描过。
如果用户线程是冻结的,那么不会有问题。如果用户线程在收集过程中修改了引用关系,就会出现两种后果:
Wilson 于 1994 年在理论上证明,当且仅当以下两个条件同时满足,会产生“对象消失”的问题:
第二个条件的描述需要很精确,不能忽略关键字眼。
要解决并发扫描时的对象消亡问题,只需要破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:
和破坏死锁的思路类似。
不太理解“以这些灰色对象为根,重新扫描”,反而是“按照扫描的那一刻的对象图进行扫描”更容易理解。
应该是以这些灰色对象为根,根据记录的被删除引用,继续扫描。把继续扫描到的对象,统统视为存活对象。
理解起来有点绕,因为第一次扫描是沿着引用链进行,删除引用后,确实没办法继续往下。但是引用关系的变化通过写屏障记录了下来。第二次扫描需要 STW。
如果没有理解错的话,这时候会产生一种错误将白标记为黑的浮动垃圾;第一种方案不会出现该情况;但两种方案都不能避免已标记为黑的对象在并发时成为浮动垃圾。
理解了这部分,再看垃圾收集器的过程示意图中的停顿和并发,不再枯燥反觉有趣了。
其实我不太理解
JVM 内存区域划分为:
Java 虚拟机栈(Java Virtual Machine Stack),线程私有,生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的线程内存模型。每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
可以使用 -Xss1024k
设置虚拟机栈的大小。默认情况下都是 1024k,只有 Windows 中取决于虚拟内存。
1 | public class StackTest_4 { |
并非只有自己写的递归方法可能引发栈内存溢出,有可能第三方库也会引发栈内存溢出。
1 | public class StackTest_5 { |
1 | public class StackTest_3 { |
1 | public class ThreadTest_1 { |
当发现 CPU 占用率居高不下时,可以尝试以下步骤:
top
,定位 cpu 占用高的进程 id。ps H -eo pid,tid,%cpu | grep pid
,进一步定位引起 cpu 占用高的线程 id。jstack pid
,根据线程 id 换算成 16进制的 nid 找到对应线程,进一步定位到问题的源码行号。1 | "thread1" #8 prio=5 os_prio=0 tid=0x00007f9bd0162800 nid=0x1061ad runnable [0x00007f9bd56eb000] |
1 | public class ThreadTest_2 { |
jstack pid
,会显示找到死锁,以及死锁涉及的线程,,并各自持有的锁还有等待的锁。堆(Heap)的特点:
既然堆有垃圾回收机制,为什么还会发生内存溢出呢?最开始的时候,我也有这样的困惑。
后来我才认识到,还在使用中的对象是不能被强制回收的,不再使用的对象不是立刻回收的。当创建对象却没有足够的内存空间时,如果清理掉那些不再使用的对象就有足够的内存空间,就不会发生内存溢出,程序只是表现为卡顿。
1 | public class HeapTest_1 { |
1 | java.lang.OutOfMemoryError: Java heap space |
堆内存溢出的发生往往需要长时间的运行,因此在排查相关问题时,可以适当调小堆内存。
jmap -heap pid
查看堆内存信息1 | public class HeapTest_2 { |
使用 jmap -heap pid
查看堆内存信息:
1 | Eden Space: |
使用 jconsole 查看堆内存信息:
当你发现堆内存占用居高不下,经过 GC,下降也不明显,如果你想查看一下堆内的具体情况,可以将其 dump 查看。
1 | public class HeapTest_3 { |
可使用 VisualVM 的 Heap Dump 功能:
也可使用 jmap -dump:format=b,file=filename.hprof pid
,需要其他分析工具搭配。
根据《Java虚拟机规范》,方法区在逻辑上是堆的一部分,但是在具体实现上,各个虚拟机厂商并不相同。对于 Hotspot 而言:
1 | public class MethodAreaTest_1 extends ClassLoader { |
不要认为自己不会写动态生成字节码相关的代码就忽略这方面的问题,如今很多框架使用字节码技术大量地动态生成类。
二进制字节码文件主要包含三类信息:
1 | public class MethodAreaTest_2 { |
1 | Classfile /C:/Users/username/Documents/github/jvm-study/target/classes/com/moralok/jvm/memory/methodarea/MethodAreaTest_2.class |
虚拟机解释器(interpreter)需要解释的字节码指令如下:
1 | 0: getstatic #2 |
索引 #2
的意思就是去常量表里查找对应项代表的事物。
1 | public class DirectMemoryTest_1 { |
1 | io 用时 1676.9797 |
1 | public class DirectMemoryTest_2 { |
1 | java.lang.OutOfMemoryError: Direct buffer memory |
这似乎是代码中抛出的异常,而不是真正的直接内存溢出?
1 | public class DirectMemoryTest_3 { |
在代码中实现手动进行直接内存的分配和释放。
1 | public class DirectMemoryTest_4 { |
本质上,直接内存的自动释放是利用了虚引用的机制,间接调用了 unsafe 的分配和释放直接内存的方法。
DirectByteBuffer 就是使用 unsafe.allocateMemory(size) 分配直接内存。DirectByteBuffer 对象以及一个 Deallocator 对象(Runnable 类型)一起用于创建了一个虚引用类型的 Cleaner 对象。
1 | DirectByteBuffer(int cap) { |
根据虚引用的机制,如果 DirectByteBuffer 对象被回收,虚引用对象会被加入到 Cleanner 的引用队列,ReferenceHandler 线程会处理引用队列中的 Cleaner 对象,进而调用 Deallocator 对象的 run 方法。
1 | public void run() { |
如果你准备过 Java 的面试,应该看到过一个问题:“String s1 = new String("abc");
这个语句创建了几个字符串对象”。这个问题曾经困扰我,当时的我不能理解这个问题想要考察的是什么?
答案中或许提及了字符串常量池,但是如果细究起来,会发现答案并不完善,有些令人困惑,甚至问题本身就有一定的误导作用。它很容易让初学者以为创建一个字符串对象和创建一个其他类型的对象在过程上是有一些区别的。
其实关键的地方在于 “abc” 而不是 new String("abc")
。
字面量(literal)是用于表达源代码中的一个固定值的表示法(notion),比如代码中的整数、浮点数、字符串。简而言之,字符串字面量就是双引号包裹的字符串,例如:
1 | String s1 = "a"; |
在 Java 中,字符串对象就是一个 String 类型的对象,因此在程序运行时,String 类型的变量 s1 指向的一定是一个 String 对象。字面量 “a” 在某一个时刻,没有经过 new 关键字,变成了一个 String 对象。
接下来我们来思考一个问题,程序中每一个字符串字面量都要对应着生成一个单独的 String 对象吗?考虑到 Java 中 String 对象是不可变的,显然相同的字符串字面量完全可以共用一个 String 对象从而避免重复创建对象。JVM 也是这样设计的,这些可以共用的 String 对象组成了一个字符串常量池。
ps: 以上的“遇到某一个字符串字面量”就是很纯粹地指代程序的源代码中出现用双引号括起来的字符串字面量。
因此,如果字符串常量池中没有值为 “abc” 的 String 对象,new String("abc")
语句将涉及两个 String 对象的创建,第一个是因为括号里的 “abc” 而在字符串常量池中生成的,第二个才是 new 关键字在堆中创建的;否则只会涉及一个 String 对象的创建。
为什么上面改用如果字符串常量池中没有值为 “abc” 的 String 对象呢?这是因为,字符串常量池里保留的 String 对象有两种产生来源:
java.lang.String#intern
主动地尝试将字符串对象放入字符串常量池。1 | public class StringTableTest_1 { |
使用 javap -v .\StringTableTest_1.class
进行反编译,摘取重要部分:
1 | Constant pool: |
在《深入理解Java虚拟机》提到:字符串常量池的位置从 JDK 7 开始,从永久代中移到了堆中。在这句话中,字符串常量池像是一个特定的内存区域,存储了 interned string 的实例。
书中使用了以下方式来验证字符串常量池的位置。
1 | public class StringTableTest_8 { |
在 JDK 8 中异常如下:
1 | Exception in thread "main" java.lang.OutOfMemoryError: Java heap space |
在 JDK 6 中异常如下:
1 | java.lang.OutOfMemoryError: PermGen space |
同时书中也提到了,在字符串常量池的位置改变后,它只用保存第一次出现时字符串对象的引用。JDK 8 中的 intern 方法可以印证该说法,方法注释中提到:如果字符串常量池中已存在相等(equals)的字符串,那就返回已存在的对象(这样原先准备加入的对象就可以释放);否则,将字符串对象加入字符串常量池中,直接返回对该对象的引用(不用像 JDK 6 时,复制一个对象加入常量池,返回该复制对象的引用)。
1 | public class StringTableTest_5 { |
实验结果证实了上述说法。
但是 xinxi 提及:字符串常量池,也称为 StringTable,本质上是一个惰性维护的哈希表,是一个纯运行时的结构,只存储对 java.lang.String
实例的引用,而不存储 String 对象的内容。当我们提到一个字符串进入字符串常量池其实是说在这个 StringTable 中保存了对它的引用,反之,如果说没有在其中就是说 StringTable 中没有对它的引用。
zyplanke 分析 StringTable 在内存中的形式时,也表达了类似的观点。
尽管这个疑问似乎不妨碍我们理解很多东西,但是深究之后,真的让人困惑,网上也没有搜集到更多的信息。字符串常量池和 StringTable 是否等价?字符串常量池更准确的说法是否是“一个保存引用的 StringTable 加上分布在堆(JDK 6 以前的永久代)中的字符串实例”?
已经好几次打开 jvm 的源码,却看不懂它到底什么意思啊!!!!!难道是时候开始学 C++ 了吗。
前面提到了第一次遇到的字符串字面量会在某一个时刻,生成对应的字符串对象进入字符串常量池,同时也提到了,字符串常量池(StringTable)的维护是懒惰的,那么这些究竟是什么时候发生的呢?
1 | public class StringTableTest_12 { |
1 | 0: new #2 // class java/lang/String |
RednaxelaFX 的文章提到:
在类加载阶段,JVM 会在堆中创建对应这些 class 文件常量池中的字符串对象实例,并在字符串常量池中驻留其引用。具体在 resolve 阶段执行。这些常量全局共享。
xinxi 的文章中补充到:
这里说的比较笼统,没错,是 resolve 阶段,但是并不是大家想的那样,立即就创建对象并且在字符串常量池中驻留了引用。 JVM规范里明确指定resolve阶段可以是lazy的。
……
就 HotSpot VM 的实现来说,加载类的时候,那些字符串字面量会进入到当前类的运行时常量池,不会进入全局的字符串常量池(即在 StringTable 中并没有相应的引用,在堆中也没有对应的对象产生)。
《深入理解Java虚拟机》中提到:
《Java虚拟机规范》之中并未规定解析阶段发生的具体时间,只要求了在执行ane-warray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic这17个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现可以根据需要来自行判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。
综上可知,字符串字面量的解析是属于类加载的解析阶段,但是《Java虚拟机规范》并未规定解析发生的具体时间,只要求在执行一些字节码指令前进行,其中包括了 ldc 指令。虚拟机的具体实现,比如 Hotspot 就在执行 ldc #indexNumber
前触发解析,根据字符串常量池中是否已存在字符串对象决定是否创建对象,并将对象推送到栈顶。
这也证实了前文中提到的字符串字面量生成字符串对象和 new 关键字无关。
使用 IDEA memory 功能,观察字符串对象的个数逐个变化。
1 | public class StringTableTest_4 { |
前文提到字符串常量池在 JDK 7 开始移到堆中,是因为考虑在方法区中的垃圾回收是比较困难的,同时随着字节码技术的发展,CGLib 等会大量动态生成类的技术的运用使得方法区的内存紧张,将字符串常量池移到堆中,可以有效提高其垃圾回收效率。
1 | public class StringTableTest_9 { |
1 | [GC (Allocation Failure) [PSYoungGen: 2048K->488K(2560K)] 2048K->856K(9728K), 0.0007745 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] |
当 size 过小,哈希碰撞增加,链表变长,效率会变低,需要增大 buckets size。
1 | public class StringTableTest_10 { |
当你需要大量缓存重复的字符串时,使用 intern 可以大大减少内存占用。
1 | public class StringTableTest_11 { |
使用 VisualVM 观察字符串和 char[] 内存占用情况,可以发现提升显著。
字符串变量的拼接,底层是使用 StringBuilder 实现:new StringBuilder().append("a").append("b").toString()
,而 toString 方法使用拼接得到的 char 数组创建一个新的 String 对象,因此 s3 和 s4 是不相同的两个对象。
1 | public class StringTableTest_2 { |
1 | 0: ldc #2 // String a |
字符串常量的拼接是在编译期间,因为已知结果而被优化为一个字符串常量。又因为 “ab” 字符串在 StringTable 中是已存在的,所以不会重新创建新对象。
1 | public class StringTableTest_3 { |
1 | 0: ldc #2 // String a |
1 | public class JvmGcTest_1 { |
1 | Heap |
根据打印的信息,组成如下:
Heap
: 堆。def new generation
: 新生代。tenured generation
: 老年代。Metaspace
: 元空间,实际上并不属于堆, -XX:+PrintGCDetails
将它的信息一起输出。新生代中的空间占比 eden:from:to
在默认情况下是 8:1:1
,与观察到的数据 8192K:1024K:1024K
一致。
新生代的空间 eden + from + to
为 10240K,符合 -Xmn10M
设置的大小。total
显示为 9216K,即 eden + from
的大小,是因为 to
的空间不计算在内。新生代可用的空间只有 eden + from
,to
空间只是在使用标记-复制算法进行垃圾回收时使用。
老年代的空间为 10240K。
目前仅 eden
中已用 2010K,约占 eden
空间的 24%。
内存地址为 16 位的 16 进制的数字,64 位机器。[0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
分别表示地址空间的开始、已用、结束的地址指针。
新生代 [0x00000000fec00000, 0x00000000ff600000)
,老年代 [0x00000000ff600000, 0x0000000100000000)
,计算可得空间大小均为 10MB。eden
中已用的空间地址为 [0x00000000fec00000, 0x00000000fedf68c8)
,空间大小为 2058440 byte,约等于 2010K。
显而易见,新生代和老生代是一片完全连续的地址空间。
1 | public static void main(String[] args) { |
1 | [GC (Allocation Failure) [DefNew: 2013K->721K(9216K), 0.0105099 secs] 2013K->721K(19456K), 0.0105455 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] |
Allocation Failure
,正常情况下,新对象总是分配在 Eden,分配空间失败,eden
的剩余空间不足以存放 7M 大小的对象,新生代发生 minor GC
。[DefNew: 2013K->721K(9216K), 0.0105099 secs]
,新生代在垃圾回收前后空间的占用变化和耗时。2013K->721K(19456K), 0.0105455 secs
,整个堆在垃圾回收前后空间的占用变化和耗时。
from
的已用空间的地址为 [0x00000000ff500000, 0x00000000ff5b45f0)
,空间大小为 738800 byte,约 721K,与 GC 后的新生代空间占用大小一致。在垃圾回收后,eden
区域存活的对象全部转移到了原 to
空间,from
和 to
空间的角色相互转换(从地址空间的信息可以看到此时 to
的地址指针比 from
的地址指针小)。eden
的已用空间的地址为 [0x00000000fec00000, 0x00000000ff33d8c0)
,空间大小为 7592128 byte,约 7.24M,比 7M 大不少。此时 eden
区域除了 byte[]
对象外,还存储了其他对象,比如为了创建 List<byte[]>
对象而新加载的类对象。
1 | public static void main(String[] args) { |
1 | [GC (Allocation Failure) [DefNew: 2013K->721K(9216K), 0.0011172 secs] 2013K->721K(19456K), 0.0011443 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] |
由于 eden
区域还能放下 512K 的对象,所以仍然只会发生一次垃圾回收。eden
区域的已用空间比例上升到 96%,已用空间的地址为 [0x00000000fec00000, 0x00000000ff3bd8d0)
,空间大小为 8116432 byte,约 7.74M,比上一次增加了 524304 byte,即 512 * 1024 + 16
。显然第二次添加时,不再因为创建 List<byte[]>
而创建额外的对象,只有创建对象所需的 512K 和 16 字节的对象头。这一刻数值的精确让人欣喜hhh。
1 | public static void main(String[] args) { |
1 | [GC (Allocation Failure) [DefNew: 2013K->721K(9216K), 0.0013580 secs] 2013K->721K(19456K), 0.0013932 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] |
在第三次添加时,由于 eden
空间不足,因此又发生了第二次垃圾回收。[DefNew: 8565K->512K(9216K), 0.0046378 secs]
,新生代的空间占用下降到了 512K,应该是在 from 中留下了第二次添加时的 512K。
在第二次添加完成后,eden
[0x00000000fec00000, 0x00000000ff3bd8d0)
和 from
[0x00000000ff500000, 0x00000000ff5b45f0)
占用的空间为 8116432 + 738800 = 8855232
约 8647.7K,略大于 8565K。很奇怪,第二次垃圾回收前,新生代的空间占用为什么有小幅度下降。8565K->8396K(19456K), 0.0046540 secs
,堆的占用空间并未发生明显下降。部分对象因为新生代空间不足,提前晋升到了老年代中。8396K - 512 K 剩余 7884K,全部晋升到老年代,符合 77% 的统计数据。eden
中加入了第三次添加时的对象,大于 512K 不少。
此时 eden
、from
、tenured
中均有不好确认成分的空间占用,比如 from 中多了 56 字节。
1 | public static void main(String[] args) { |
1 | Heap |
在 Eden 空间肯定不足而老年代空间足够的情况下,大对象会直接在老年代中创建,此时不会发生 GC。
1 | public static void main(String[] args) { |
1 | waiting... |
当新生代和老年代的空间均不足时,在尝试 GC 和 Full GC 后仍不能成功分配对象,就会发生 OutOfMemoryError
。
1 | public static void main(String[] args) { |
1 | [GC (Allocation Failure) [DefNew: 2013K->721K(9216K), 0.0012274 secs][Tenured: 8192K->8912K(10240K), 0.0113036 secs] 10205K->8912K(19456K), [Metaspace: 3345K->3345K(1056768K)], 0.0125751 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] |
当 Thread-0
发生 OutOfMemoryError
后,main
线程仍然正常运行。
当创建的大对象 + 对象头的容量小于等于 eden
,如果 GC 后的存活对象可以放入 to
,那么还是会先在 eden
中创建大对象。
在本案例中,又会马上发生一次 GC,大对象提前晋升到老年代中。
1 | public static void main(String[] args) { |
1 | [GC (Allocation Failure) [DefNew: 2013K->693K(9216K), 0.0015517 secs] 2013K->693K(19456K), 0.0015828 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] |
尽管最终大部分对象提前晋升到老年代,但是可以看到第二次 GC 前的新生代空间占用,可见数组分配时,所需空间刚好为 Eden 空间大小时,还是会在 eden 创建对象。
尽管总体上有迹可循,但是 GC 的具体情况,仍然需要具体分析,有很多分支情况未一一确认。
获取指定 Bean 的入口方法是 getBean,在 Spring 上下文刷新过程中,就依次调用 AbstractBeanFactory#getBean(java.lang.String)
方法获取 non-lazy-init
的 Bean。
1 | public Object getBean(String name) throws BeansException { |
作为公共处理逻辑,由 AbstractBeanFactory 自己实现。
1 | protected <T> T doGetBean( |
1 | public Object getSingleton(String beanName, ObjectFactory<?> singletonFactory) { |
createBean 是创建 Bean 的入口方法,由 AbstractBeanFactory 定义,由 AbstractAutowireCapableBeanFactory 实现。
1 | protected Object createBean(String beanName, RootBeanDefinition mbd, Object[] args) throws BeanCreationException { |
常规的创建 Bean 的具体工作是由 doCreateBean 完成的。
1 | protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final Object[] args) throws BeanCreationException { |
创建 Bean 实例,并使用 BeanWrapper 封装。实例化的方式:
为创建出的实例填充属性,包括解析当前 bean 所依赖的 bean。
1 | protected void populateBean(String beanName, RootBeanDefinition mbd, BeanWrapper bw) { |
在填充完属性后,实例就可以进行初始化工作:
1 | protected Object initializeBean(final String beanName, final Object bean, RootBeanDefinition mbd) { |
让 Bean 在初始化中,感知(获知)和自身相关的资源,如 beanName、beanClassLoader 或者 beanFactory。
1 | private void invokeAwareMethods(final String beanName, final Object bean) { |
1 | protected void invokeInitMethods(String beanName, final Object bean, RootBeanDefinition mbd) throws Throwable { |
在调用初始化方法前后,BeanPostProcessor 先后进行两次处理。其实和 BeanPostProcessor 相关的代码都非常相似:
1 | public Object applyBeanPostProcessorsBeforeInitialization(Object existingBean, String beanName) throws BeansException { |
以下代码片段一度让我困惑,从注释看,初始化 Bean 实例的工作包括了 populateBean 和 initializeBean,但是 initializeBean 方法的含义就是初始化 Bean。在 initializeBean 方法中,调用了 invokeInitMethods 方法,其含义仍然是调用初始化方法。
在更熟悉代码后,我有一种微妙的、个人性的体会,在 Spring 源码中,有时候视角的变化是很快的,痕迹是很小的。如果不加以理解和区分,很容易迷失在相似的描述中。以此处为例,“初始化 Bean 和 Bean 的初始化”扩展开来是 “Bean 工厂初始化一个 Bean 和 Bean 自身进行初始化”。
1 | // Initialize the bean instance. |
在注释这一行,视角是属于 BeanFactory(AbstractAutowireCapableBeanFactory)。从工厂的视角,面对一个刚刚创建出来的 Bean 实例,需要完成两方面的工作:
在万事俱备之后,就是 Bean 自身的初始化工作。由于 Spring 的高度扩展性,这部分并不只是单纯地调用初始化方法,还包含 Aware 接口和 BeanPostProcessor 的相关处理,前者偏属于 Java 对象层面,后者偏属于 Spring Bean 层面。
在认同 BeanPostProcessor 的处理属于 Bean 自身初始化工作的一部分后,@PostConstruct 注解的方法被称为 Bean 的初始化方法也就不那么违和了,因为它的实现原理正是 BeanPostProcessor,这仍然在 initializeBean 的范围内。
1 | public void refresh() throws BeansException, IllegalStateException { |
准备此 context 以供刷新,设置其启动日期和活动标志以及执行属性源的初始化。
编写管理资源的容器时,可以参考。
1 | protected void prepareRefresh() { |
告诉子类刷新内部 bean 工厂并返回,返回的实例类型为 DefaultListableBeanFactory。在这里完成了配置文件的读取,初步注册了 bean 定义。
我大概这辈子都不会想理清楚这里面关于 XML 文件的解析过程,但是我知道可以在这里观察到 beanFactory 因为配置文件注册了哪些 bean。
1 | protected ConfigurableListableBeanFactory obtainFreshBeanFactory() { |
配置 BeanFactory 以供在此 context 中使用,例如 context 的类加载器和一些后处理器,手动注册一些单例。
ignoreDependencyInterface 和 registerResolvableDependency 在理解之后比单纯地记忆它们有趣许多。
1 | protected void prepareBeanFactory(ConfigurableListableBeanFactory beanFactory) { |
在创建 Bean 开始前注册的单例,都属于手动注册的单例 manualSingletonNames
1 | public void registerSingleton(String beanName, Object singletonObject) throws IllegalStateException { |
在标准初始化后修改内部 beanFactory,默认什么都不做。
实例化并调用所有在 context 中注册的 beanFactory 后处理器,需遵循顺序规则。具体的处理被委托给 PostProcessorRegistrationDelegate。
1 | protected void invokeBeanFactoryPostProcessors(ConfigurableListableBeanFactory beanFactory) { |
invokeBeanFactoryPostProcessors 方法堪比裹脚布。
关于调用顺序的规则:
可能新增 beanDefinition 的情况:
1 | public static void invokeBeanFactoryPostProcessors( |
注册拦截 bean
创建的 bean
后处理器。具体的处理被委托给 PostProcessorRegistrationDelegate。
1 | protected void registerBeanPostProcessors(ConfigurableListableBeanFactory beanFactory) { |
registerBeanPostProcessors 相比之下是一条清新的裹脚布。这里特别区分 3 种类型的 Bean 后处理器:
ApplicationListenerDetector 既是 MergedBeanDefinitionPostProcessor,又是 DestructionAwareBeanPostProcessor,在初始化后将 listener 加入,在销毁前将 listener 移除。
1 | public static void registerBeanPostProcessors( |
添加 BeanPostProcessor 时
1 | public void addBeanPostProcessor(BeanPostProcessor beanPostProcessor) { |
初始化消息源。 如果在此 context 中未定义,则使用父级的。
1 | protected void initMessageSource() { |
初始化 ApplicationEventMulticaster
。 如果上下文中未定义,则使用 SimpleApplicationEventMulticaster
。可以看得出代码的结构和 initMessageSource 是类似的。
1 | protected void initApplicationEventMulticaster() { |
可以重写模板方法来添加特定 context 的刷新工作。默认情况下什么都不做。
获取侦听器 bean
并注册。无需初始化即可添加
1 | protected void registerListeners() { |
添加 ApplicationListener。
后处理器 ApplicationListenerDetector 在 processor chain 的最后,最终会将创建的代理添加为监听器。什么情况下会出现代码中预防的情况呢?
1 | public void addApplicationListener(ApplicationListener<?> listener) { |
实例化所有剩余的(非惰性初始化)单例。以 context 视角,是完成内部 beanFactory 的初始化。
几乎可以只关注最后的 beanFactory.preInstantiateSingletons()
。
1 | protected void finishBeanFactoryInitialization(ConfigurableListableBeanFactory beanFactory) { |
确保所有非惰性初始化单例都已实例化,同时还要考虑 FactoryBeans
。 如果需要,通常在工厂设置结束时调用。
先对集合进行 Copy 再迭代是很常见的处理方式,可以有效保证迭代时不受原集合影响,也不会影响到原集合。
1 | @Override |
最后一步,完成 context 刷新,比如发布相应的事件。
1 | protected void finishRefresh() { |
当 Java
程序启动的时候,Java
虚拟机会调用 java.lang.ClassLoader#loadClass(java.lang.String)
加载 main
方法所在的类。
1 | public Class<?> loadClass(String name) throws ClassNotFoundException { |
根据注释可知,此方法加载具有指定二进制名称的类,它由 Java
虚拟机调用来解析类引用,调用它等同于调用 loadClass(name, false)
。
1 | protected Class<?> loadClass(String name, boolean resolve) |
根据注释可知,java.lang.ClassLoader#loadClass(java.lang.String, boolean)
同样是加载“具有指定二进制名称的类”,此方法的实现按以下顺序搜索类:
findLoadedClass(String)
以检查该类是否已加载。loadClass
方法。如果父·类加载器为空,则使用虚拟机内置的类加载器。findClass(String)
方法来查找该类。如果使用上述步骤找到了该类(找到并定义类),并且解析标志为 true
,则此方法将对生成的 Class
对象调用 resolveClass(Class)
方法。鼓励 ClassLoader
的子类重写 findClass(String)
,而不是此方法。除非被重写,否则此方法在整个类加载过程中以 getClassLoadingLock
方法的结果进行同步。
注意:父·类加载器并非父类·类加载器(当前类加载器的父类),而是当前的类加载器的
parent
属性被赋值另外一个类加载器实例,其含义更接近于“可以委派类加载工作的另一个类加载器(一个帮忙干活的上级)”。虽然绝大多数说法中,当一个类加载器的parent
值为null
时,它的父·类加载器是引导类加载器(bootstrap class loader
),但是当看到findBootstrapClassOrNull
方法时,我有点困惑,因为我以为会看到语义类似于loadClassByBootstrapClassLoader
这样的方法名。从注释和代码的语义上看,bootstrap class loader
不像是任何一个类加载器的父·类加载器,但是从类加载的机制设计上说,它是,只是因为它并非由 Java 语言编写而成,不能实例化并赋值给parent
属性。findBootstrapClassOrNull
方法的语义更接近于:当一个类加载器的父·类加载器为null
时,将准备加载的目标类先当作启动类(Bootstrap Class
)尝试查找,如果找不到就返回null
。
需要加载的类可能很多很多,我们很容易想到如果可以并行地加载类就好了。显然,JDK
的编写者考虑到了这一点。
此方法返回类加载操作的锁对象。为了向后兼容,此方法的默认实现的行为如下。如果此 ClassLoader
对象注册为具备并行能力,则该方法返回与指定类名关联的专用对象。 否则,该方法返回此 ClassLoader
对象。
简单地说,如果 ClassLoader
对象注册为具备并行能力,那么一个 name
一个锁对象,已创建的锁对象保存在 ConcurrentHashMap
类型的 parallelLockMap
中,这样类加载工作可以并行;否则所有类加载工作共用一个锁对象,就是 ClassLoader
对象本身。
这个方案意味着非同名的目标类可以认为在加载时没有冲突?
1 | protected Object getClassLoadingLock(String className) { |
ClassLoader
对象注册为具有并行能力”呢?AppClassLoader
中有一段 static
代码。事实上 java.lang.ClassLoader#registerAsParallelCapable
是将 ClassLoader
对象注册为具有并行能力唯一的入口。因此,所有想要注册为具有并行能力的 ClassLoader
都需要调用一次该方法。
1 | static { |
java.lang.ClassLoader#registerAsParallelCapable
方法有一个注解 @CallerSensitive
,这是因为它的代码中调用的 native
方法 sun.reflect.Reflection#getCallerClass()
方法。由注释可知,当且仅当以下所有条件全部满足时才注册成功:
Object
类除外)都注册为具有并行能力。static
代码块中来实现。如果写在构造器方法里,并且通过单例模式保证只实例化一次可以吗?答案是不行的,后续会解释这个“注册”行为在构造器方法中是如何被使用以及为何不能写在构造器方法里。Java
虚拟机加载类时,总是会先尝试加载其父类,又因为加载类时会先调用 static
代码块,因此父类的 static
代码块总是先于子类的 static
代码块。你可以看到 AppClassLoader->URLClassLoader->SecureClassLoader->ClassLoader
均在 static
代码块实现注册,以保证满足以上两个条件。
简单地说就是保存了类加载器所属 Class
的 Set
。
1 | @CallerSensitive |
方法 java.lang.ClassLoader.ParallelLoaders#register
。ParallelLoaders
封装了一组具有并行能力的加载器类型。就是持有 ClassLoader
的 Class
实例的集合,并保证添加时加同步锁。
1 | // private 修饰,只有其外部类 ClassLoader 才可以使用 |
但是以上的注册过程只是起到一个“标记”作用,没有涉及和锁相关的代码,那么这个“标记”是怎么和真正的锁产生联系呢?ClassLoader
提供了三个构造器方法:
1 | private ClassLoader(Void unused, ClassLoader parent) { |
ClassLoader
的构造器方法最终都调用 private
修饰的 java.lang.ClassLoader#ClassLoader(java.lang.Void, java.lang.ClassLoader)
,又因为父类的构造器方法总是先于子类的构造器方法被执行,这样一来,所有继承 ClassLoader
的类加载器在创建的时候都会根据在创建实例之前是否注册为具有并行能力而做不同的操作。
使用“注册”的代码也解释了 java.lang.ClassLoader#registerAsParallelCapable
为了满足调用成功的第一个条件为什么不能写在构造器方法中,因为使用这个机制的代码先于你在子类构造器方法里编写的代码被执行。
同时,不论是 loadLoader
还是 getClassLoadingLock
都是由 protect
修饰,允许子类重写,来自定义并行加载类的能力。
todo: 讨论自定义类加载器的时候,印象里似乎对并行加载类的提及比较少,之后留意一下。
加载类之前显然需要检查目标类是否已加载,这项工作最终是交给 native
方法,在虚拟机中执行,就像在黑盒中一样。
todo: 不同类加载器同一个类名会如何判定?
1 | protected final Class<?> findLoadedClass(String name) { |
正如在代码和注释中所看到的,正常情况下,类的加载工作先委派给自己的父·类加载器,即 parent
属性的值——另一个类加载器实例。一层一层向上委派直到 parent
为 null
,代表类加载工作会尝试先委派给虚拟机内建的 bootstrap class loader
处理,然后由 bootstrap class loader
首先尝试加载。如果被委派方加载失败,委派方会自己再尝试加载。
正常加载类的是应用类加载器 AppClassLoader
,它的 parent
为 ExtClassLoader
,ExtClassLoader
的 parent
为 null
。
在网上也能看到有人提到以前大家称之为“父·类加载器委派机制”,“双亲”一词易引人误解。
这样设计很明显的一个目的就是保证核心类库的类加载安全性。比如 Object
类,设计者不希望编写代码的人重新写一个 Object
类并加载到 Java
虚拟机中,但是加载类的本质就是读取字节数据传递给 Java
虚拟机创建一个 Class
实例,使用这套机制的目的之一就是为了让核心类库先加载,同时先加载的类不会再次被加载。
通常流程如下:
AppClassLoader
调用 loadClass
方法,先委派给 ExtClassLoader
。ExtClassLoader
调用 loadClass
方法,先委派给 bootstrap class loader
。bootstrap class loader
在其设置的类路径中无法找到 BananaTest
类,抛出 ClassNotFoundException
异常。ExtClassLoader
捕获异常,然后自己调用 findClass
方法尝试进行加载。ExtClassLoader
在其设置的类路径中无法找到 BananaTest
类,抛出 ClassNotFoundException
异常。AppClassLoader
捕获异常,然后自己调用 findClass
方法尝试进行加载。注释中提到鼓励重写 findClass
方法而不是 loadClass
,因为正是该方法实现了所谓的“双亲委派模型”,java.lang.ClassLoader#findClass
实现了如何查找加载类。如果不是专门为了破坏这个类加载模型,应该选择重写 findClass
;其次是因为该方法中涉及并行加载类的机制。
默认情况下,类加载器在自己尝试进行加载时,会调用 java.lang.ClassLoader#findClass
方法,该方法由子类重写。AppClassLoader
和 ExtClassLoader
都是继承 URLClassLoader
,而 URLClassLoader
重写了 findClass
方法。根据注释可知,该方法会从 URL
搜索路径查找并加载具有指定名称的类。任何引用 Jar
文件的 URL
都会根据需要加载并打开,直到找到该类。
过程如下:
name
转换为 path
,比如 com.example.BananaTest
转换为 com/example/BananaTest.class
。URL
搜索路径 URLClassPath
和 path
中获取 Resource
,本质上就是轮流将可能存放的目录列表拼接上文件路径进行查找。URLClassLoader
的私有方法 defineClass
,该方法调用父类 SecureClassLoader
的 defineClass
方法。1 | protected Class<?> findClass(final String name) |
URLClassLoader
拥有一个 URLClassPath
类型的属性 ucp
。由注释可知,URLClassPath
类用于维护一个 URL
的搜索路径,以便从 Jar
文件和目录中加载类和资源。URLClassPath
的核心构造器方法:
1 | public URLClassPath(URL[] urls, |
URLClassLoader
调用 sun.misc.URLClassPath#getResource(java.lang.String, boolean)
方法获取指定名称对应的资源。根据注释,该方法会查找 URL
搜索路径上的第一个资源,如果找不到资源,则返回 null
。
显然,这里的 Loader
不是我们前面提到的类加载器。Loader
是 URLClassPath
的内部类,用于表示根据一个基本 URL
创建的资源和类的加载器。也就是说一个基本 URL
对应一个 Loader
。
1 | public Resource getResource(String name, boolean check) { |
获取下一个 Loader
,其实根据 index
从一个存放已创建 Loader
的 ArrayList
中获取。
1 | private synchronized Loader getNextLoader(int[] cache, int index) { |
index
到存放已创建 Loader
的列表中去获取(调用方传入的 index
从 0
开始不断递增直到超过范围)。index
超过范围,说明已有的 Loader
都找不到目标 Resource
,需要到未打开的 URL
中查找。URL
中取出(pop
)一个来创建 Loader
,如果 urls
已经为空,则返回 null
。1 | private synchronized Loader getLoader(int index) { |
根据指定的 URL
创建 Loader
,不同类型的 URL
会返回不同具体实现的 Loader
。
URL
不是以 /
结尾,认为是 Jar
文件,则返回 JarLoader
类型,比如 file:/C:/Users/xxx/.jdks/corretto-1.8.0_342/jre/lib/rt.jar
。URL
以 /
结尾,且协议为 file
,则返回 FileLoader
类型,比如 file:/C:/Users/xxx/IdeaProjects/java-test/target/classes/
。URL
以 /
结尾,且协议不会 file
,则返回 Loader
类型。1 | private Loader getLoader(final URL url) throws IOException { |
以 FileLoader
的 getResource
为例,如果文件找到了,就会将文件包装成一个 FileInputStream
,再将 FileInputStream
包装成一个 Resource
返回。
1 | Resource getResource(final String name, boolean check) { |
从上文可知,ClassLoader
调用 findClass
方法查找类的时候,并不是漫无目的地查找,而是根据设置的类路径进行查找,不同的 ClassLoader
有不同的类路径。
以下是通过 IDEA
启动 Java
程序时的命令,可以看到其中通过 -classpath
指定了应用·类加载器 AppClassLoader
的类路径,该类路径除了包含常规的 JRE
的文件路径外,还额外添加了当前 maven
工程编译生成的 target\classes
目录。
1 | C:\Users\xxx\.jdks\corretto-1.8.0_342\bin\java.exe -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:52959,suspend=y,server=n -javaagent:C:\Users\xxx\AppData\Local\JetBrains\IntelliJIdea2022.3\captureAgent\debugger-agent.jar -Dfile.encoding=UTF-8 -classpath "C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\charsets.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\ext\access-bridge-64.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\ext\cldrdata.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\ext\dnsns.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\ext\jaccess.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\ext\jfxrt.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\ext\localedata.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\ext\nashorn.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\ext\sunec.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\ext\sunjce_provider.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\ext\sunmscapi.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\ext\sunpkcs11.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\ext\zipfs.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\jce.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\jfr.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\jfxswt.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\jsse.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\management-agent.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\resources.jar;C:\Users\xxx\.jdks\corretto-1.8.0_342\jre\lib\rt.jar;C:\Users\xxx\IdeaProjects\java-test\target\classes;C:\Program Files\JetBrains\IntelliJ IDEA 2022.3.3\lib\idea_rt.jar" org.example.BananaTest |
启动·类加载器 bootstrap class loader
,加载核心类库,即 <JRE_HOME>/lib
目录中的部分类库,如 rt.jar
,只有名字符合要求的 jar
才能被识别。 启动 Java 虚拟机时可以通过选项 -Xbootclasspath
修改默认的类路径,有三种使用方式:
-Xbootclasspath:
:完全覆盖核心类库的类路径,不常用,除非重写核心类库。-Xbootclasspath/a:
以后缀的方式拼接在原搜索路径后面,常用。-Xbootclasspath/p:
以前缀的方式拼接再原搜索路径前面.不常用,避免引起不必要的冲突。在 IDEA
中编辑启动配置,添加 VM
选项,-Xbootclasspath:C:\Software
,里面没有类文件,启动虚拟机失败,提示:
1 | Error occurred during initialization of VM |
扩展·类加载器 ExtClassLoader
,加载 <JRE_HOME>/lib/ext/
目录中的类库。启动 Java
虚拟机时可以通过选项 -Djava.ext.dirs
修改默认的类路径。显然修改不当同样可能会引起 Java
程序的异常。
应用·类加载器 AppClassLoader
,加载应用级别的搜索路径中的类库。可以使用系统的环境变量 CLASSPATH
的值,也可以在启动 Java 虚拟机时通过选项 -classpath
修改。
CLASSPATH
在 Windows
中,多个文件路径使用分号 ;
分隔,而 Linux
中则使用冒号 :
分隔。以下例子表示当前目录和另一个文件路径拼接而成的类路径。
.;C:\path\to\classes
.:/path/to/classes
事实上,AppClassLoader
最终的类路径,不仅仅包含 -classpath
的值,还会包含 -javaagent
指定的值。
方法 defineClass
,顾名思义,就是定义类,将字节数据转换为 Class
实例。在 ClassLoader
以及其子类中有很多同名方法,方法内各种处理和包装,最终都是为了使用 name
和字节数据等参数,调用 native
方法获得一个 Class
实例。
以下是定义类时最终可能调用的 native
方法。
1 | private native Class<?> defineClass0(String name, byte[] b, int off, int len, |
其方法参数有:
name
,目标类的名称。byte[]
或 ByteBuffer
类型的字节数据,off
和 len
只是为了定位传入的字节数组中关于目标类的字节数据,通常分别是 0 和字节数组的长度,毕竟专门构造一个包含无关数据的字节数组很无聊。ProtectionDomain
,保护域,todo:source
,CodeSource
的位置。defineClass
方法的调用过程,其实就是从 URLClassLoader
开始,一层一层处理后再调用父类的 defineClass
方法,分别经过了 SecureClassLoader
和 ClassLoader
。
此方法是再 URLClassLoader
的 findClass
方法中,获得正确的 Resource
之后调用的,由 private
修饰。根据注释,它使用从指定资源获取的类字节来定义类,生成的类必须先解析才能使用。
1 | private Class<?> defineClass(String name, Resource res) throws IOException { |
Resource
类提供了 getBytes
方法,此方法以字节数组的形式返回字节数据。
1 | public byte[] getBytes() throws IOException { |
在 getByteBuffer
之后会缓存 InputStream
以便调用 getBytes
时使用,方法由 synchronized
修饰。
1 | private synchronized InputStream cachedInputStream() throws IOException { |
在这个例子中,Resource
的实例是 URLClassPath
中的匿名类 FileLoader
以 Resource
的匿名类的方式创建的。
1 | public InputStream getInputStream() throws IOException |
URLClassLoader
继承自 SecureClassLoader
,SecureClassLoader
提供并重载了 defineClass
方法,两个方法的注释均比代码长得多。
由注释可知,方法的作用是将字节数据(byte[]
类型或者 ByteBuffer
类型)转换为 Class
类型的实例,有一个可选的 CodeSource
类型的参数。
1 | protected final Class<?> defineClass(String name, |
方法中只是简单地将 CodeSource
类型的参数转换成 ProtectionDomain
类型,就调用 ClassLoader
的 defineClass
方法。
1 | private ProtectionDomain getProtectionDomain(CodeSource cs) { |
根据注释可知,此方法会返回给定 CodeSource
对象的权限。此方法由 protect
修饰,AppClassLoader
和 URLClassLoader
都有重写。当前 ClassLoader
是 AppClassLoader
。
AppClassLoader#getPermissions
,添加允许从类路径加载的任何类退出 VM的权限。
1 | protected PermissionCollection getPermissions(CodeSource codesource) |
SecureClassLoader#getPermissions
,添加一个读文件或读目录的权限。
1 | protected PermissionCollection getPermissions(CodeSource codesource) |
SecureClassLoader#getPermissions
,延迟设置权限,在创建 ProtectionDomain
时再设置。
1 | protected PermissionCollection getPermissions(CodeSource codesource) |
ProtectionDomain
的相关构造器参数:
CodeSource
PermissionCollection
,如果不为 null
,会设置权限为只读,表示权限在使用过程中不再修改;同时检查是否需要设置拥有全部权限。ClassLoader
Principal[]
这样看来,SecureClassLoader
为了定义类做的处理,就是简单地创建一些关于权限的对象,并保存了 CodeSource->ProtectionDomain
的映射作为缓存。
抽象类 ClassLoader
中最终用于定义类的 native
方法 define0
,define1
,define2
都是由 private
修饰的,ClassLoader
提供并重载了 defineClass
方法作为使用它们的入口,这些 defineClass
方法都由 protect
final
修饰,这意味着这些方法只能被子类使用,并且不能被重写。
1 | protected final Class<?> defineClass(String name, byte[] b, int off, int len) |
主要步骤:
preDefineClass
前置处理defineClassX
postDefineClass
后置处理确定保护域 ProtectionDomain
,并检查:
java.*
类package
)中其余类的签名者相匹配1 | private ProtectionDomain preDefineClass(String name, |
确定 Class
的 CodeSource
位置。
1 | private String defineClassSourceLocation(ProtectionDomain pd) |
这些 native
方法使用了 name
,字节数据,ProtectionDomain
和 source
等参数,像黑盒一样,在虚拟机中定义了一个类。
在定义类后使用 ProtectionDomain
中的 certs
补充 Class
实例的 signer
信息,猜测在 native
方法 defineClassX
方法中,对 ProtectionDomain
做了一些修改。事实上,从代码上看,将 CodeSource
包装为 ProtectionDomain
传入后,除了 defineClassX
方法外,其他地方都是取出 CodeSource
使用。
1 | private void postDefineClass(Class<?> c, ProtectionDomain pd) |