前言
在深入理解的jvm和类库实现的过程中,我们可以手动编译jdk并调试源代码,这是研究java底层(内存管理、字节码执行、类加载机制)的重要途经,通过断点调试JDK源码,可以直观理解java程序运行原理。我们在编译过程中需要用到GCC编译器,GNU Make的主要是因为jdk在开发过程中,大量代码是由C和C++进行实现的。这其中同时也需要java代码的编译,java代码的编译是通过一种自举的方式进行,也叫bootstrapping的方式完成对其中java代码的编译。通过引导jdk生成初始新的jdk,新的jdk进行编译jdk中的java代码,直到构建完整的jdk,而第一代的javac编译器是团队手动通过c编写的,将java源代码转化为字节码。当然在后面学习过程中,我们能了解到jdk中的C/C++/汇编语言主要是实现JVM的功能,而java语言只要是实现java的核心类库。本期专栏将分为四部分进行讲解,介绍java的自动内存管理、Class文件结构与执行引擎、编译器优化以及多线程并发的实现原理。由于博客篇幅原因,本期先介绍自动内存管理。
自动内存管理
java是在虚拟机的自动内存管理机制之下进行的,不再需要像C++一样为每一个new操作去写配对的delete/free代码。
运行时数据区域
在java虚拟机执行java程序的过程会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束
而建立和销毁。
程序计数器
**程序计数器(Program Counter Register)**是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在jvm的概念模型里,字节码解释器就是通过改变这个计数器的值来选取下一条要执行的字节码指令,它是程序控制流的指示器,分支、循环、调整、异常处理、线程恢复等基础功能都需要依赖这个计数器完成。由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
java虚拟机栈
与程序计数器一样,**Java虚拟机栈(Java Virtual Machine Stack)**也是线程私有的,它的生命周期与线程相同。它描述的是java方法执行的线程内存模型,每个方法被执行的时候,java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口。每一个方法被调用直到执行完毕,就对应着一个栈帧在虚拟机栈中从入栈到出栈。在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。
本地方法栈
**本地方法栈(Native Method Stacks)**与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。
Java堆
对于Java应用程序来说,**Java堆(Java Heap)**是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,在java中,几乎所有的对象实例都是在这里分配内存的。Java堆是垃圾收集器管理的内存区域。根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。
方法区
**方法区(Method Area)**与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。
运行时常量池
**运行时常量池(Runtime Constant Pool)**是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
直接内存
**直接内存(Direct Memory)**并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
对象的创建
当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。假设java堆内存是绝对规整的,一边是使用过的java堆,一边是空闲的,这时候就可以用指针的方式进行分配内存,这种分配方式称为指针碰撞,如果java堆中的内存不是规整的,已被使用的内存和空闲的内存相互交错在一起,这时候就记录一个列表,来记录哪些内存块是可用的,这种方法对应的分配方式称为空闲列表。而java堆是否规整,又对应着不同的垃圾收集器。
除了分配空间外,我们还需要考虑一个问题,就是如果在并发的情况下,如何在分配空间的同时保证线程安全,一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性。
CAS(Compare-And-Swap)即比较并交换,是一种无锁的原子操作,用于多线程环境下实现同步机制,避免使用传统锁(synchronized关键字),CAS 操作包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。CAS 指令执行时,会先比较内存位置 V 处的值是否等于预期原值 A,如果相等,则将该位置的值更新为新值 B;如果不相等,则不做任何操作。它其实是一种乐观锁的方式,以 “假设无冲突” 为前提,通过无锁和重试机制实现原子操作。在java中的AtomicInteger、CocurrentHashMap(部分操作)、JVM内存分配都用到了它。
另一种方式是每个线程在java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),那个线程要分配内存,就在哪个线程本地缓冲区中分配,如果分配完了,再分配新的缓存区时才需要同步锁定。
内存分配完成后,虚拟机会将分配到的内存空间都初始化为零值。接下来,java虚拟机还会为对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息、对象的哈希码(这个实际上会延后到真正调用Object::hasCode()方法时计算)、对象的GC分代年龄等信息。这些信息存放在对象头之中。从虚拟机的角度,对象已经创建完成,不过在java程序的角度,还需要执行构造函数。
对象的内存布局
在HotSpot虚拟机中,对象在堆内存中可划分为三个部分,对象头、实例数据、对齐填充。
- 对象头主要包括两类信息。一类是存储对象自身运行时数据,如哈希码、GC分代年龄、锁状态标志等。另一类是类型指针,即对象指向它的类型元数据的指针,java虚拟机通过这个指针来确定该对象是哪个类的实例。
- 实例数据部分是对象真正存储的有效信息,即我们在程序代码中所定义的各种类型的字段内容,包括父类继承下来的以及子类中定义的。
- 对齐填充,这并不是必然存在的,仅仅起着占位符的作用,HotSpot虚拟机中的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,如果对象实例数据部分没有对齐的化,就需要对齐填充来补全。
对象的访问定位
java程序通过栈上的reference数据来操作堆上的具体对象,reference类型是一个指向对象的引用,它访问对象的方式分为两种,使用句柄以及直接指针两种方式。
- 句柄,java堆中将会划出一块内存作为句柄池,reference中存储的就是这个对象的句柄地址,句柄在包括了对象实例数据与类型数据各种具体的地址信息。使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
- 直接指针,java堆中对象的内存布局就必须考虑访问类型数据的相关信息,reference存储的直接是对象的地址。如果是直接访问对象本身的话,就不需要多一次间接访问的开销。使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。
垃圾收集器与内存分配策略
在java内存运行时区域的程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。在这几个区域就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。
引用计数算法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。客观地说,引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。但是它很难解决对象之间相互循环引用的问题。举个例子:
对象objA和objB都有字段instance,赋值令objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。
可达性分析算法
这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象
起始节点集可以包括在虚拟机栈(栈帧中的本地变量表)中引用的对象、在方法区中类静态属性引用的对象、在方法区中常量引用的对象、在本地方法栈中JNI(即通常所说的Native方法)引用的对象等等。
分代垃圾收集算法
分代收集理论
分代收集理论实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。在Java堆划分出不同的区域之后(新生代和老年代),垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。
标记-清除算法(老年代垃圾收集Major GC)
最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,在1960年由Lisp之父John McCarthy所提出。如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,然后统一回收这些对象所占用的内存空间。
它的主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
标记-复制算法(新生代垃圾收集Minor GC)
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它的基本思想是将内存分为两块,每次只使用其中一块。当这一块内存用完后,将还存活的对象复制到另一块内存中,然后将原来的内存空间全部清理掉。
标记-整理算法(老年代垃圾收集Major GC)
针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。不过标记-整理是移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。所以是否移动回收后的存活对象是一项优缺点并存的风险决策:
一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。
总结
本期详细介绍了JVM的自动内存管理,包括运行时数据区域布局、对象创建的过程,以及垃圾收集器与内存分配策略。
参考文献
深入理解Java虚拟机:JVM高级特性与最佳实践 周志明
写在文末
有疑问的友友,欢迎在评论区交流,笔者看到会及时回复。
请大家一定一定要关注!!!
请大家一定一定要关注!!!
请大家一定一定要关注!!!
友友们,你们的支持是我持续更新的动力~