Java 内存区域
程序计数器
每个线程私有的一块较小的内存空间。通过改变计数器的值来选取下一个需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复都依赖于它。
虚拟机栈
线程私有的一块内存,用于描述方法执行的内存模型:每个方法在执行的而同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。当线程请求的栈深度大于虚拟机允许的深度时,抛出 StackOverflowError
堆
Java 虚拟机所管理的内存中最大的一块区域。所有线程共享 这块堆内存。所有的实例对象都存放在此
方法区
线程共享的一块区域,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。Hotspot 使用永久代来实现方法区,但其他虚拟机并未这么做,所以他们不等价
运行时常量池
它是方法区的一部分。Class 文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息时常量池。运行期间也可将新的常量放入池中,例如 String 类的 intern()方法
HotSpot 虚拟机对象探秘
对象创建
虚拟机遇到一条 new 指令时,首先检查能否在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有则先执行类加载。
类加载通过后,对象所需内存的大小在类加载完成后便可确定,虚拟机将为新生对象分配内存。java 堆中的内存可以是绝对规整的,也可以是零散的,取决于 GC 是否带有压缩整理的功能,不通的虚拟机表现不通。如果是规整的,只需分配内存的指针移动一段与对象大小相等的内存即可,这种方法叫“指针碰撞”(因多线程,所以采用 CAS 移动指针,保证原子性)。如果是零散的,那么虚拟机维护了一个表用于记录哪些内存块是可用的,这种分配方式被称为“空闲列表”。
对象创建后,虚拟机要对对象进行必要的设置,例如这个对象属于哪个类的实例、如何找到类的元数据信息、哈希码、GC 分代年龄等,这些信息存放在对象的对象头中。
上面的工作完成后,对象已经产生了,所有字段都还为零,最后执行<init>方法,把对象按照程序员的医院进行初始化,这样才得到一个真正可用的对象。
对象的内存布局
对象在内存中存储的布局可以分为:对象头、实例数据和对齐填充。
对象头包括两部分信息,一部分是用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。另一部分是类型指针,及对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果是数组,对象头中还有一块用于记录数组长度的数据。
OutOfMemeoryError 异常实战
Java 堆溢出
除了程序计数器外,虚拟机中其他几个运行时区域都可能发生 OutOfMemeoryError 异常,下文简称 OOM
1 | VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError |
上述代码限制 java 堆的大小为 20M(最小值-Xms 最大值-Xmx),通过参数-XX:+HeapDumpOnOutOfMemoryError
可以让虚拟机在出现内存溢出异常时 Dumo 出当前内存对转储快照,以便事后进行分析。
虚拟机栈和本地方法栈溢出
使用-Xss
设定栈内存容量大小,例:VM Args: -Xss2M
递归过多或线程太多,大量的局部变量可能导致栈溢出。再不能减少线程数的情况下,只能通过减少最大堆和减少栈容量来换取更多的线程。
方法区和运行时常量池溢出
运行时常量池是方法区的一部分,JDK1.7 开始,HotSpot 开始将方法区移出“永久代”。
在 JDK1.6 及之前的版本中,可以通过设置-XX:PermSize
和-XX:MaxPermSize
限制方法区的大小,从而间接的限制常量池的容量。例:VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
方法区用于存放 Class 相关的信息,如雷鸣、访问修饰符、常量池、字段描述、方法描述等。测试方法是产生大量的类去填满方法区直到溢出,但是实验比较麻烦,可以使用 CGLib 直接操作字节码运行时产生大量的动态类。Spring、Hibernate 在对类增强时都会用到 CGLib 这类字节码技术。增强的类越多就需要越大的方法区来保证动态生成的 Class 可以加在到内存中。
本机直接内存溢出
DirectMemory 容量可以通过-XX:MaxDirectMemorySize
指定,如果不指定,则默认与 java 堆最大值(-Xmx 指定)一样,例:VM Args -Xmx20M -XX:MaxDirectMemorySize=10M
垃圾收集器和内存分配策略
首先介绍几个回收的概念:
- 新生代 GC(Minor GC):发生在新生代,因为大多数对象都是朝生夕灭,所以 Minor GC 非常频繁,回收速度也非常快
- 老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Full GC 通常会伴随着至少一次 Minor GC,Full GC 的速度一般会比 Minor GC 慢 10 倍以上
引用计数法
给对象添加一个引用计数器,每当有地方引用它时,计数器的值就加 1,引用失效就减 1,计数器为 0 时不可能再被使用,即可回收。
引用计数法思想简单,判定效率高,Python 和游戏脚本 Squirrel 都是用它回收内存,但是主流的 java 虚拟机都没有选用引用计数法,主要原因是难以解决对象之间互相引用的问题
可达性分析算法
主流的商用语言 Java,C#,Lisp 都是使用可达性分析法来判断对象是否存活的。这个算法的思路是通过一系列的称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,如果一个对象不在引用链上,则证明此对象不可用。
java 语言中可作为 GC Roots 的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中的引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI(即一般说的额 Native 方法)引用的对象
再谈引用
JDK1.2 之前,java 中的引用的定义很传统:如果 reference 类型的数据中存储的数字代表的是另一块内存的其实地址,就称这块内存代表着一个引用。这种定义很纯粹戴氏过于狭隘。我们希望能描述这样的一类对象:当内存空间还足够时,则保留在内存中,如果内存空间中在垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都是符合这样的应用场景。
JDK1.2 之后,java 对应用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用 4 种,则 4 种引用强度一次减弱
- 强引用:类似于
Object obj = new Object()
这类的引用,只要强引用存在对象就不会被回收 - 软引用:描述一些有用但非必须的对象。在系统将要发生内存溢出异常之前,会把这些对象进行第二次回收,如果这次 GC 结束还是没有足够的内存,才会抛出内存溢出异常。可以使用 SoftReference 类来实现软引用
- 弱引用:描述非必须对象,但强度比软引用更弱。无论内存是否足够,弱引用对象都会在下次 GC 进行回收(也就是逃过 1 次回收)。可以使用 WeakReference 类来实现弱引用
- 虚引用:最弱的引用关系。虚引用不会对被引用对象产生任何影响,也无法通过弱引用获取到对象实例,它存在的唯一目的就是能在这个对象被回收时收到一个系统通知,可以使用 PhantomReference 类来实现虚引用
回收对象
对于不可达的对象,判断对象有没有覆盖 finalize()方法,如果对象没有覆盖或者 finalize()方法已经被虚拟机执行过,则直接回收;如果有待执行的 finalize()方法,则进入一个 F-Queue 队列之中,并在稍后有一个低优先级的 Finalizer 线程去执行它,如果在 finalize()方法中,对象又将自己关联了其他变量,则成功救活自己,不会被 GC,反之执行完遍直接回收
注意,任何对象的 finalize()方法都只会被执行一次,也就是只能救活自己一次,所以尽量避免使用这个方法,有些教材描述它用来关闭外部资源之类的工作,其实使用 try-finally 或其他方式可以做的更好
垃圾收集算法
标记-清除算法
首先标记出所有待回收的对象,回头统一回收,上面已经介绍了标记过程,该算法有两个不足之处:一是标记清除的过程效率不高,二是标记清除后产生了大量不连续内存碎片,碎片过多后续分配较大对象时会无法分配
复制算法
现在的商用虚拟机都是采用复制算法来回收新生代,将一块内存按 1:1 的比例分为 AB 两部分,每次使用内存只使用其中一部分,例如只使用 A 而 B 完全不使用,进过几次 GC 后,A 碎片化严重,当 A 无法分配给新对象内存时,将 A 中的还存活的对象复制到 B 上,然后将 A 整个清理掉。这样就解决了内存碎片化问题,实现简单运行高效。但是算法的代价时缩小了一半的内存,代价太大了。
根据 IBM 调查,98%的对象朝生夕死,所以 HotSpot 按 8:1:1 将新生代分配为 Eden、From Survivor 和 To Survivor 三部分,两块 Survivor 中总有一块是空闲的,碎片化严重后进行一次 Minor GC,将存活的对象通过复制算法拷贝到空闲的 Survivor 上,并将这些对象的年龄+1,也就是说 Eden 区域的对象年龄都是 0,而 Survivor 上的对象可能为 0 也可能有一定的年龄,当 Survivor 上的对象年龄到达 15 时,该对象就进入永久代。也不总是这样,对于一些较大的对象,当 Survivor 空间不足时,直接进入永久代
标记整理算法
新生代采用的时复制算法,以便更快的淘汰那些生命周期很短的对象,老年代不是这种算法,而是采用标记整理算法。首先仍然进行标记,然后不是直接清除,二是让所有存活的对象都向一端移动,于是成活和非成活的对象就被分成两部分,清理掉存活对象以外的区域即可。这样就不会像复制算法一样进行全量复制。
分代算法
新生代和老年代
垃圾收集器
上述收集算法如果算方法论,那么垃圾收集器就是内存回收的具体实现。因为不存在完美的虚拟机,在不同是场景下需要使用不通的收集器,所以虚拟机中往往不止有一种 GC 收集器。
上述是 HotSpot 中使用的收集器,上半部分为新生代收集器,下半部分为老年代收集器,两者之间有连线说明可以搭配使用。
Serial 收集器
在 JDK1.3.1 之前,该收集器是新生代收集器的唯一选择,它是一个单线程收集器,并且所有的其他工作线程都要停止直到它收集结束,也就是著名的“stop the world”。虽然看起来 Serial 是一个古老的宝贝,但是它依然是虚拟机运行在 Client 模式下的默认新生代收集器,它简单高效,单线程没有线程交互的开销,专心的做垃圾收集自然可以获得最高的单线程收集效率。
ParNew 收集器
ParNew 收集器其实就是 Serial 的多线程版本。除了 Serial 外,也只有 parNew 能和 CMS 配合工作,在 JDK1.5 中 HotSpot 推出了 CMS 收集器,这是第一款真正意义上的并发收集器,是一次实现了垃圾收集线程和用户线程同时工作。不幸的是 CMS 只能用于老年代
Parallel Scavenge 收集器
它是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器。它的目的是控制吞吐量。所谓吞吐量就是用户代码时间和 GC 时间的比例
Serial Old 收集器
它是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记整理算法,它主要是给 Client 模式下的虚拟机使用。在 Server 端有两大用途:配合 Parallel Scavenge 收集器使用和作为 CMS 收集器的后备预案
Parallel Old 收集器
它是 Parallel Scavenge 收集器的老年代版,新生代 Parallel Scavenge 收集器一直处于尴尬的状态,因为如果采用了它,老年代只能选择 Serial Old,而 Serial Old 在服务端应用性能上实在拖累,直到 Parallel Old 出现,吞吐量优先收集器终于有了比较名副其实的组合
CMS 收集器
它设计的目标是减小用户代码停顿(stop the world)的时间,满足了在重视响应速度的应用服务器的需求,它是基于标记清除算法实现的,只能用于老年代,它的运作过程相对于前面几种收集器更复杂一点,分为 4 步骤:初始标记、并发标记、重新标记、并发清除。初始标记和重新标记仍然需要“stop the world”。初始标记仅仅是标记一下 GC Roots 能关联到的对象,速度很快,并发标记阶段就是进行 GC Roots Tracing 的过程,重新标记是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那部分对象,这个阶段停顿时间比初始标记稍微长一些,但远比并发标记的时间短。
CMS 是一款优秀的收集器,主要有点事:并发收集、低停顿。但 CMS 还远没有达到完美的成都,有以下 3 个明显缺点:
- 对 CPU 资源敏感,并发阶段虽然不会导致用户线程停顿但因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
- 可能会导致一次 Full GC,垃圾收集阶段用户线程需要运行,所以并发收集阶段如果用户线程产生大量对象,可能导致内存不足,此时虚拟机将采用后背预案,临时启用 Serial Old 收集器进行老年代回收,当然也就 stop the world,停顿时间就很长了。
- 它是基于标记清除算法实现的,会产生大量内存碎片,无法分配大对象,不得不提前触发 Full GC,为了解决这个问题,CMS 提供了一个
-XX:+UseCMSCompactAtFullCollection
开关(默认开启),用于 CMS 收集器顶不住要 Full GC 时开启碎片整理合并,内存整理的过程无法并发,碎片没有了但是停顿时间延长了。虚拟机还提供了-XX:CMSFullGCsBeforeCompaction
参数,表示执行多少次不压缩(整理)Full GC 后触发一次整理(默认时 0,表示每次 Full GC 都进行压缩整理)
G1 收集器
G1 收集器是当今收集器技术发展最前沿成果之一。它是一款面向服务端应用的垃圾收集器,HotSpot 赋予它的使命是替换掉 CMS 收集器。它具备如下特性:
- 并行和并发,并行是充分利用多核 CPU 来缩短 stop the world 停顿时间,并发是 G1 工作时,仍能通过并发的方式让 java 程序继续执行
- 分代收集,不需要其他收集器配合,独立完成新生代和老年代的垃圾回收
- 空间整合,与 CMS 的标记清除算法不同,G1 整体看是基于标记整理算法,局部上看是基于复制算法。总之它不会产生内存碎片,不会出现无法分配大对象而触发 Full GC 的情况
- 可预测的停顿:停顿时间是 G1 和 CMS 共同的关注点,除此之外 G1 还追求停顿时间可预测,能让使用者明确指定在一个长度为 M 毫秒的时间片段上,GC 的时间不超过 N 毫秒,这几乎已经是实时 java(RTSJ)的垃圾收集器的特性了
使用 G1 收集器时,java 堆的内存布局和其他收集器差别很大,它将整个堆划分为多个大小相等的独立区域(不需要连续),虽然还保留新生代和老年代的概念,但他们不再物理隔离,而是共同组成某一块区域。
G1 之所以能预测停顿时间,是因为它可以有计划的避免在整个 java 堆中进行全区域的垃圾收集。它跟踪每个区域里垃圾堆积的价值(回收所需的空间以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的手机时间,优先回收价值最大的区域,这种策略保证了在有限时间内尽可能的收集效率。
GC 日志
使用 -verbose:gc
或 -XX:+PrintGC
这两个参数可以创建基本的 GC 日志,使用 -XX:+PrintGCDetails
可以创建更加详细的日志,使用-XX:+PrintGCDateStamps
打印当前时间,使用-XX:+PrintGCTimeStamps
打印日志输出时,vm 启动了多久。
1 | 2015-05-26T14:45:37.987-0200: 151.126: |
2015-05-26T14:45:37.987-0200
– GC 事件(GC event)开始的时间点.151.126
– GC 事件的开始时间,相对于 JVM 的启动时间,单位是秒(Measured in seconds).GC
– 用来区分(distinguish)是 Minor GC 还是 Full GC 的标志(Flag). 这里的 GC 表明本次发生的是 Minor GC.Allocation Failure
– 引起垃圾回收的原因. 本次 GC 是因为年轻代中没有任何合适的区域能够存放需要分配的数据结构而触发的.DefNew
– 使用的垃圾收集器的名字. DefNew 这个名字代表的是: 单线程(single-threaded), 采用标记复制(mark-copy)算法的, 使整个 JVM 暂停运行(stop-the-world)的年轻代(Young generation) 垃圾收集器(garbage collector).629119K->69888K
– 在本次垃圾收集之前和之后的年轻代内存使用情况(Usage).(629120K)
– 年轻代的总的大小(Total size).1619346K->1273247K
– 在本次垃圾收集之前和之后整个堆内存的使用情况(Total used heap).(2027264K)
– 总的可用的堆内存(Total available heap).0.0585007 secs
– GC 事件的持续时间(Duration),单位是秒.[Times: user=0.06 sys=0.00, real=0.06 secs]
– GC 事件的持续时间,通过多种分类来进行衡量:
内存分配和回收策略
- 优先在新生代 Eden 分配,Eden 没有足够空间时,虚拟机将发起 Minor GC,回收后仍空间不足的话,只好通过分配担保机制提前转移到老年代去。
- 大对象直接进入老年代,大对象指需要大量连续内存的对象,例如超长的字符串或数组,产生大对象往往需要提前进行 GC,给大对象腾出空间
- 长期存活的对象进入老年代,每个对象都会有一个年龄,代表它熬过的 Minor GC 的次数,当年龄到达 15 时,进入老年代
- 动态对象年龄判断,如果 Survivor 空间中相同年龄所有对象大小的综合大于 Survivor 空间的一半,大于或等于改年龄的对象可直接进入老年代,无需达到设置或默认的年龄
虚拟机性能监控与故障处理
JDK 命令行工具
JDK 的 bin 目录除了java.exe
、javac.exe
还有一些工具主要用于监控虚拟机和故障处理
- jps:类似于 UNIX 的 ps 命令,显示进程,虚拟机打印出的 id 和操作系统的 id 是一致的
- jstat:用于监控虚拟机的运行,显示进程中类的装载、内存、垃圾收集、JIT 编译等
- jinfo:实时的查看和调整虚拟机各项参数
- jmap:用于生成 java 堆转储快照
- jhat:虚拟机堆转储快照分析工具,用于分析 jmap 生成的 dump 文件,功能较弱
- jstack:生成虚拟机当前时刻的线程快照
- HSDIS:JIT 生成代码反汇编
JDK 可视化工具
JConsole 是较老的可视化工具,下面着重介绍 VisualVM,是到目前为止功能最强大的运行监视和故障处理程序。双击 /bin/jvisualvm.exe
即可启动
VisualVM 对实际运行程序的影响很小。可以直接在生产环境使用。它一开始就具备了插件扩展的机制,VisualVM 可以做到:
- 显示虚拟机进程以及进程的配置、环境信息(jps、jinfo)
- 监视应用程序的 CPU、GC、堆、方法去以及线程的信息(jstat、jstack)
- dump 以及分析堆转储快照(jmap、jhat)
- 方法级的程序性能分析,找出被调用最多、运行时间最长的方法
- 离线程序快照:收集程序的运行时配置、线程 dump、内存 dump 等信息,建立一个快照
VisualVM 就像一个操作系统,只提供一些简单的功能,只有安装了一些软件(插件)以后才能对虚拟机进行更好的监控,可以直接通过界面上的插件页面安装
调优与实战
给 java 虚拟机分配超大的内存可能会因为老年代的内存十分大,导致一次 Full GC 需要 20 秒,如果能保证十几小时或一天才会出现一次 Full GC,就可以在深夜通过定时任务,用代码触发 Full GC。
控制 Full GC 的关键在于是否大多数对象符合朝生夕灭的原则,即大多数对象生存时间不长,尤其不能有成批的长时间的大对象产生,才能保证老年代的稳定。
如果不能保证老年代的稳定,可以采用起若干个小型 java 应用,前端搭建 nginx 做负载均衡,这样能避免长时间 GC,同时合理的使用了物理机的资源
类文件结构
虚拟机无关性
java 代码先编译为字节码,jvm 再在运行时,把字节码通过 JIT 转换为二进制码,java 虚拟机在设计之初就可以的将 java 的规范拆分成 java 语言规范和 java 虚拟机规范,以便其他语言也可以运行在 java 虚拟机之上,例如:Clojure、Groovy、JRuby、Jython、Scala 等。实现虚拟机语言无关的关键在于虚拟机只和 class 文件关联,其他语言也可以生成对应的 class 文件,java 语言中的各种变量、关键字和运算符号最终是由多条字节码命令组合而成,字节码能提供的语义描述能力肯定比 java 语言本身更强大,所以一些 java 不支持的语言特性不代表字节码不能支持,这也为其他有别于 java 的语言特性提供了基础
Class 类文件的结构
任何一个 class 文件都对应着唯一的一个类或接口的定义信息,但是反过来不成立,类或接口也可以通过类加载器直接生成,不一定以磁盘的形式存在,也可以是一串通过网络请求来的字节流。
class 文件是一组以 8 位字节为基础单位的二进制流,由无符号数和表两种数据类型构成
魔数与 class 文件的版本
每个 class 文件的头 4 个字节称为魔数,他唯一的作用是确定这个文件是否是能被虚拟机接受的 class,例如 gif 或 jpg 文件头中都有魔数。紧接着魔数的 4 个字节存储 class 文件的版本号,第 5、6 个字节表示次版本号,第 7、8 个字节是主版本号。JDK 能向下兼容,执行老版本的 class,但是拒绝执行超过其版本号的 class
常量池
紧接着主次版本号之后的是常量池入口,常量池中主要存放两大类常量:字面量和符号引用。常量例如字符串、声明为 final 的常量值等。符号引用则属于编译原理的概念,包括三类常量:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。class 文件不会保存各个方法、字段最终的内存布局信息,虚拟机运行时需要先从常量池中获取到对应的符号引用,再在类创建时或运行时解析到具体的内存地址中
虚拟机类加载机制
类加载的时机
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载 7 个阶段。其中验证、准备、解析 3 个阶段统称为连接
类加载过程
加载
先通过一个类的全限定名来获取定义此类的二进制字节流,然后将流中代表静态存储的结构转换为方法区的运行时数据结构,最后在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据访问入口。
验证
验证是连接的第一步,目的是确保 Class 文件中的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。java 语言是安全的,类似数组越界的问题,在编译成 class 文件时就检测出来了,但是非 java 语言也能生成 class 文件,所以虚拟机需要进行一步验证
准备
正式为类分配类变量(static 修饰的变量,不包含实例变量),例如
public static int value = 123
准备阶段过后,value 的值为 0,初始化后 value 才为 123,但如果 value 被 final 修饰了,那准备阶段 value 已经是 123 了解析
是虚拟机将常量池内的符号引用替换为直接引用的过程。
初始化
初始化是类加载过程的最后一步,前面的过程中,除了加载阶段用户可以通过自定义类加载器参与,其余动作完全由虚拟机主导控制,到了初始化阶段才开始执行类中定义的 java 程序代码。
准备阶段变量已经被赋予了系统要求的初始值,初始化阶段则根据程序员的主观计划初始化类变量和其他资源,例如static{}
块
类加载器
即加载 class 文件这个动作的代码块被称为类加载器,用户可以自己决定如何加载 class,也就是上文说的,可以是网络请求来的二进制流,或数据库中的二进制流等。自定义类加载器的功能在类层次划分、OSGi、热部署、代码加密等领域大放异彩
类与类加载器
比较两个类是否“相等”或 instanceof 的结果,只有这两个类是由同一个加载器加载的前提下才有意义。例如有一个 A.class 文件,虚拟机加载了一次这个 class 文件,程序员又自定义了一个类加载器加载了 A.class,即使是同一个文件,内存中实际有两个 A 类。
双亲委派模型
首先双亲委派的英文是(parents delegation),因为多了一个 s,被翻译为“双”,其实只有一个父级。
从虚拟机的角度,只存在两种类加载器:一种是启动类加载器(Bootstrap ClassLoader),由 C++实现,是虚拟机的一部分;另一种是 java 实现,独立于虚拟机外,并且全都继承了 java.lang.ClassLoader。
从 java 开发人员的角度看,jdk 提供了 3 种类加载器:启动类加载器(Bootstrap ClassLoader)、扩展类加载器(负责加载/lib/ext 目录下的 class)、应用程序类加载器(加载 classpath 下文件,是程序默认的加载器)
双亲委派模型要求除了顶级启动类加载器外,其余的类加载器都应该有自己的父类加载器,并且不是继承,而是组合的方式(持有一个父类对象)来复用父类加载器。
工作过程:如果一个类加载器收到了加载请求,先不自己处理,而是交给父类处理,最终传送到了顶层,如果父类反馈自己无法完成这个请求,子加载器才尝试自己去加载。
好处是无论哪个类加载器要加载类,最终都会抵达顶层进行加载,这样 equals 和 instance 才有意义
虚拟机字节码执行引擎
栈帧
栈帧是支持方法调用和执行的数据结构。每个方法从调用开始至执行结束都对应着一个栈帧先入栈再出栈。
局部变量表
用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽 Slot 为最小单位,Slot 中能存放 8 中类型:boolean、byte、char、short、int、float、reference 和 returnAddress。其中只有 double 和 long 是 64 位数据类型,其他的是 32 位,long 和 double 数据类型读写被分割成读写两次 32 位数据,因为局部变量表建立在线程堆栈上,是线程私有的,所以读写两个连续的 Slot 虽然不是原子操作,但是不会引起安全问题。
Slot 可以重用,当一个变量在当前方法的后续代码中不再使用时,当前 Slot 可以交给其他变量使用,节省了栈帧空间。
//todo
//todo
//todo
//todo
//todo
//todo
//todo
//todo
早期(编译期)优化
存在 3 种状态的编译期:
- 前端编译期:将.java 转换为.class
- 后端编译器(JIT):将*.class 转变为机器码的过程
- 静态提前编译器(AOT):直接把*.java 编译成本地的机器码
Javac 编译器
它本身是由 java 语言编写的。编译大致分为 3 个过程:
- 解析与填充符号表过程
- 插入式注解处理器的注解处理过程(即编译器注解)
- 分析与字节码生成过程
先通过词法分析将源码中的字符进行标记,最小单位为一个 Token,然后进行语法分析,根据 Token 序列,构造出抽象语法树。
JDK1.5 之后引入了注解,注解和普通的 java 代码一样,只在运行期间发挥作用。后来 JDK1.6 又引入了一组编译期间的注解,可以看作是编译器的插件,通过这些插件,可以读取修改添加抽象语法树中的任意元素。如果这些注解修改了语法树,则要重新进行解析和填充符号。编译器注解很强大,甚至代码注释都可以访问到。
语法分析之后,无法保证源码程序是符合逻辑的,所以需要进行语义分析,对代码的上下文进行检查。
审查之后进行解语法糖。java 中的语法糖主要包括泛型、变长参数、自动装箱/拆箱等。
最后进行字节码生成,如果用户代码中没有提供任何构造函数,编译器会自动添加一个没有参数的访问性(public、protexted 或 private)与当前类一致的默认构造函数。除了构造器,还有一些其他代码优化工作,例如字符串的加操作会替换为 StringBuffer 或 StringBuilder 的 append 操作。
语法糖
泛型
如果没有泛型,那 ArrayList 这个 class 不知道自己会被 push 什么类型的对象,于是只能写死接收 Object 类型,于是什么对象都能 push 进去,就没有编译期检查了。java 的泛型是伪泛型,例如一个
List<User>
,在编译期会检查 push 的实例 instanceOf 是否为 User,但是最后编译的结果还是List<Object>
。下面的方法无法重载:1
2
3
4
5
6public static void method(List<String> list) {
// ...
}
public static void method(List<Integer> list) {
// ...
}从 signature 属性中可以看出,泛型擦除只是方法的 code 属性中的字节码被擦除了,实际元数据中还保留了泛型,可以通过反射获取参数化的类型。
自动装箱、拆箱
1
2
3
4
5
6
7
8
9
10
11
12// 源码
List<Integer> list = Arrays.asList(1,2,3,4);
int a = list.get(0) + list.get(1)
// 编译后
List list = Arrays.asList(new Integer[] {
Integer.valueOf(1),
Integer.valueOf(2),
Integer.valueOf(3),
Integer.valueOf(4),
})
int a = list.get(0).intValue() + list.get(1).intValue();
// todo
// todo
// todo
// todo
// todo
// todo
// todo
// todo
// todo
// todo