- [深入理解java虚拟机](#深入理解java虚拟机) - [Java内存区域和内存溢出异常](#java内存区域和内存溢出异常) - [运行时数据区](#运行时数据区) - [程序计数器](#程序计数器) - [Java虚拟机栈(Java Virutal Machine Stack)](#java虚拟机栈java-virutal-machine-stack) - [局部变量](#局部变量) - [本地方法栈](#本地方法栈) - [Java堆](#java堆) - [方法区](#方法区) - [运行时常量池](#运行时常量池) - [直接内存](#直接内存) - [Hotspot虚拟机对象探秘](#hotspot虚拟机对象探秘) - [对象创建](#对象创建) - [类加载](#类加载) - [内存分配方式](#内存分配方式) - [TLAB](#tlab) - [初始化](#初始化) - [对象布局](#对象布局) - [对象头](#对象头) - [实例数据](#实例数据) - [对齐填充](#对齐填充) - [对象的访问定位](#对象的访问定位) - [垃圾收集器和内存分配策略](#垃圾收集器和内存分配策略) - [对象是否应当被回收](#对象是否应当被回收) - [引用计数算法](#引用计数算法) - [可达性分析算法](#可达性分析算法) - [引用类型](#引用类型) - [垃圾回收细节](#垃圾回收细节) - [回收方法区](#回收方法区) - [判断类是否可回收](#判断类是否可回收) # 深入理解java虚拟机 ## Java内存区域和内存溢出异常 ### 运行时数据区 java程序在虚拟机运行时会将其所管理的内存区域划分为若干个不同的数据区。java运行时数据区的结构如下: 在这里插入图片描述 #### 程序计数器 程序计数器是一块较小的内存空间,其可以被看做是当前线程所执行字节码的行号指示器。`在虚拟机的概念模型中,字节码解释器在工作时会修改程序计数器的值,从而选取下一条需要执行的字节码指令`。分支、循环、跳转、异常处理、线程恢复等基础功能都依赖计数器来完成。 在java虚拟机中,多线程是通过`线程轮流切换,并分配处理器的执行时间片`来实现的。故而,在任一给定时刻,一个处理器只会执行一个线程中的指令(即给定时刻一个处理器只和一个线程进行绑定)。为了线程切换后,仍然能恢复到线程上次执行的位置,`每个线程都会有一个独立的程序计数器`。各个线程之间的程序计数器互不干扰,独立存储,该类内存为`线程私有`的内存。 > 由于JVM支持通过`本地方法`来调用c++/c等其他语言的native方法,故而: > - 如果线程正在执行的是java方法,计数器记录的是正在执行的虚拟字节码指令地址 > - 如果线程正在执行的是native方法,计数器的值为空 #### Java虚拟机栈(Java Virutal Machine Stack) 和程序计数器一样,java虚拟机栈也是线程私有的,其声明周期和线程相同。 虚拟机栈描述了java方法执行时的内存模型: - 每个方法在执行时都会创建一个栈帧,用于存储局部变量、操作数栈、动态链接、方法出口等信息 - 每个方法从调用直至执行完成的过程,对应一个栈帧在虚拟机栈中入栈、出栈的过程 ##### 局部变量 局部变量中存放了编译器可知的各种数据类型,如下: - 基本数据类型(primitives) - 对象引用 - returnAddress类型(指向一条字节码指令的地址) > 其中,64位长度的long和double数据类型会占用2个局部变量空间,其余数据类型只占用一个。 > > 局部变量表所需的空间大小在编译时就已经确定,在运行时进入一个方法时,该方法需要的局部变量表大小就已经确定,并且`方法执行过程中不会改变局部变量表的大小` 在java虚拟机规范中,针对该区域定义了两种异常状况: - `StackOverflowError`:线程所请求的栈深度大于虚拟机所允许的栈深度,将抛出StackOverflowError - `OutOfMemoryError`:如果虚拟机栈可以动态拓展,且拓展时无法申请到足够的内存,将会抛出`OutOfMemoryError` #### 本地方法栈 本地方法栈和虚拟机栈的作用类似,区别如下: - 虚拟机栈为虚拟机执行Java方法服务 - 本地方法栈为虚拟机用到的Native方法服务 和虚拟机栈相同,本地方法栈也会抛出OutOfMemoryError和StackOverflowError #### Java堆 Java堆是java虚拟机所管理内存中最大的部分,`java堆为所有线程所共享,在虚拟机创建时启动`。java堆内存用于存放对象实例,几乎所有的对象实例堆在该处分配内存。 Java堆是垃圾收集器管理的主要区域,在很多时候也被称为`GC堆`。从垃圾收集的角度看,现代收集器基本都采用分代收集的算法,故而java堆内存还可以进一步细分为`新生代和老年代`。 虽然Java堆内存为线程所共享的区域,但从内存分配的角度看,Java堆中可能划分出多个线程私有的分配缓冲区(Thead Local Allocation Buffer,TLAB)。 但是,不论Java堆内存如何划分,其存放的内容都为对象实例。Java堆内存可以处于物理上不连续的内存空间中,只需在逻辑上连续即可。通常,java堆内存都是可拓展的(通过-Xms和-Xmx控制),如果在为实例分配内存时,没有足够的内存空间进行分配,并且堆也无法再进行拓展时,将会抛出`OutOfMemoryError`。 #### 方法区 方法区和java堆一样,是各个线程共享的区域,其用于存储已被虚拟机加载的类信息、常量、静态变量、JIT编译后的代码等数据。 jvm规范对方法区的限制比较宽松,方法区的内存并不需要在物理上连续,并可选择固定大小或可拓展。除此之外,方法区还可以选择不实现垃圾回收。 通常,发生在方法区的垃圾回收比较罕见,对方法区的垃圾回收主要是针对常量池的回收和对类型的卸载。类型卸载触发的条件比较苛刻。 当方法区无法满足内存分配的需求时,将抛出OutOfMemoryError。 ##### 运行时常量池 运行时常量池是方法区的一部分。在class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放在编译期生成的各种字面量和符号引用。 `该部分内存将会在类加载后,进入方法区的运行时常量池进行存放`。 > 在java程序运行时,也能将新的常量放入池中,例如`String.intern()`方法。 #### 直接内存 直接内存不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域,但该部分内存也会被频繁使用,并且可能导致OutOfMemoryError。 在JDK 1.4中,引入了NIO,其可以使用native函数库直接分配堆外内存,然后通过一个存储在Java堆内的DirectByteBuffer对象操作堆外内存。这样能够在部分场景中显著提高性能,避免在java堆和native堆中来回复制数据。 并且,直接内存并不会受java堆大小的限制,但仍然会受物理机物理内存大小的限制。 ### Hotspot虚拟机对象探秘 #### 对象创建 ##### 类加载 虚拟机在遇到new指令时,首先会去方法区常量池中进行匹配,检查是否有对应的符号引用,并检查该符号引用对应的类是否已经被加载、解析、初始化。如果尚未被加载,则先执行该类的加载过程。 在加载该类后,对象所需的内存大小便可完全确定,会为该对象的创建分配内存空间。 ##### 内存分配方式 在java堆内存中的内存分配存在如下几种方式: - 指针碰撞: 如果Java堆内存中内存是绝对规整的,所有用过的指针都放在一边,空闲内存放在另一边,那么可以通过一个中间的指针来划分使用的空间和空闲空间 - 当分配内存空间时,仅需将指针向空闲空间的那个方向移动即可 - FreeList:如果Java中的堆内存并不规整,已使用的内存空间和空闲内存空间相互交错,此时并无法使用指针碰撞的方式来分配内存。虚拟机会维护一个列表,列表中维护空闲的内存块。 - 在内存分配时,会从列表中找到一块足够大的空间划分给对象,并更新列表上的空闲内存记录 选择采用哪种内存分配方式取决于java堆内存是否规整,而java堆是否规整则取决于采用的垃圾收集器是否带有`压缩整理`功能。 > 故而,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞;而使用CMS和Mark-Sweep算法的收集器时,通常采用FreeList ##### TLAB 对象创建是非常频繁的行为,多线程场景下容易出现并发安全的问题。即使使用指针碰撞的方式来分配内存空间,也会出现争用问题。解决内存分配下的并发问题存在两种方案: - 对分配内存空间的操作进行同步,可以采用CAS等方案保证分配内存动作的原子性 - 将内存分配的动作按照线程的不同划分在不同区域中,即每个线程先在java堆内存中预先分配一小块内存,称为TLAB,每个线程都在自身的TLAB中分配内存空间 - 只有当线程的TLAB使用完并需要分配新的TLAB时,才需要同步锁定操作,这样能减少同步锁定这一耗时操作的次数 虚拟机是否采用TLAB,可以通过`-XX:+/-UseTLAB`参数来设定。 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为0,若使用TLAB,则该操作可提前到分配TLAB时。该操作可以保证对象的实例字段在不赋值初始值时就能使用,程序能够访问这些字段数据类型所对应的零值。 ##### 初始化 在为对象分配完空间后,会执行``方法进行初始化。 #### 对象布局 在Hotspot虚拟机中,对象在内存中的布局可以分为3块区域: - 对象头(Header) - 实例数据(Instance Data) - 对齐填充(Padding) ##### 对象头 对象头中包含两部分信息: - 对象的运行时数据,如hash code、gc分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等 - 该部分在32bit和64bit的虚拟机中,长度分别为32bit和64bit,被官方称为`MarkWord` - 类型指针:该部分信息指向对象对应`类元数据`的指针,虚拟机会通过该指针来确定对象是哪个类的实例。 - 数组长度:如果对象是一个java数组,那么对象头中还需要记录数组的长度 ##### 实例数据 实例数据为对象真正存储的有效信息,即程序代码中定义的各种类型字段内容,无论是从父类继承的还是子类中定义的。 实例数据中的存储顺序会收到虚拟机分配策略参数和字段在java源码中定义顺序的影响。Hotspot虚拟机默认的分配策略为longs/doubles, ints, shorts/chars, bytes/booleans、oops(Ordinary Object Pointers),相同宽度的字段会被分配到一起。 ##### 对齐填充 对齐填充并不是自然存在的,仅起占位符的作用。hotsopt虚拟机自动内存管理系统要求对象起始地址必须是8字节的整数倍,即对象大小必须为8字节的整数倍。 对象头刚好是8字节整数倍,故而当对象的实例数据没有对对齐,需要通过对象填充来对齐。 #### 对象的访问定位 java程序需要通过虚拟机栈上的reference来操作java堆中具体的对象。目前,访问堆上对象有两种主流的方式: - 句柄访问:java堆中会划分一块区域来作为句柄池,references存储的地址即为对象句柄的地址,句柄中则包含了对象实例数据地址和对象类型数据地址 - 使用句柄访问时,reference中存储的是句柄地址,即使对象被移动(在垃圾回收时非常普遍),也只需要修改句柄中的实例数据指针,不需要修改虚拟机栈中的reference - 直接指针访问:采用直接指针访问时,堆内存中无需维护句柄池,而java的堆对象中则包含类型数据的地址。reference中直接会存储java堆对象的地址,而可以通过堆对象中存储的类型数据地址再次访问类型数据 - 使用直接指针访问时,访问速度更改,在访问实例数据时能够节省一次内存查找的开销。 在Hotspot实现中,采用的是直接指针访问方案。 ## 垃圾收集器和内存分配策略 在上一章节中提到的java运行时数据区域中,java虚拟机栈、本地方法栈、程序计数器三个区域是线程私有的,生命周期和线程一致,无需考虑垃圾回收问题,在方法结束/线程终止时内存会随之回收。 但是,`方法区`和`java堆内存`这两个区域则不同,内存的分配和回收都是动态的,垃圾收集器主要关注这部分内存。 ### 对象是否应当被回收 堆中几乎存放着所有的java对象,垃圾收集器在对对内存进行回收前,需要确定哪些对象可以被回收,哪些对象不能被回收。 #### 引用计数算法 该算法会为对象添加一个引用计数器,每当有一个地方引用该对象时,计数器加一;当引用失效时,计数器则减一;技术器为0代表该对象不再被引用。 但是,java虚拟机并没有采用引用计数算法来管理内存,主要原因是其难以解决对象之间循环引用的问题。 #### 可达性分析算法 在主流的商用程序语言(JAVA/C#等)的主流实现中,都通过可达性分析来判定对象是否可以被回收的。该算法核心是`根据一系列被称为GC Roots的对象来作为起点,从这些节点开始向下搜索,搜索时所走过的路径被称为引用链,当一个对象GC Roots没有任何的引用链相连接时,即从GC Roots到该对象不可达时,证明该对象是不可用的,可以被回收`。 在Java语言中,GC Roots包含如下内容: - 虚拟机栈中所引用的对象 - 方法区中静态属性应用的对象 - 方法区中常量所引用的对象 - 本地方法栈中JNI所引用的对象 ### 引用类型 在JDK 1.2之后,Java对引用的概念进行了扩充,引入了如下的引用类型概念: - 强引用(Strong Reference) - 软引用(Soft Reference) - 弱引用(Weak Reference) - 虚引用(Phantom Reference) 上述四种引用类型,引用强度逐级递减: - `强引用`:对于强引用类型,只要强引用还存在,垃圾收集器永远不会回收掉被引用对象 - `软引用`:软引用用于描述一些有用但非必须的对象。对于软引用关联着的对象,在系统即将要发生内存溢出的异常之前,会将这些引用列进回收范围之内进行二次回收。如果此次回收之后还没有足够的内存,才会抛出内存溢出异常。 - 即在抛出OOM之前,SoftReference会被清空,如果清空后内存仍然不够,就会实际抛出OOM - `弱引用`:弱引用用于描述非必须对象,但其强度比软引用更弱一些。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。 - 在垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象 - `虚引用`:虚引用是最弱的引用关系,一个对象是有虚引用存在,完全不会对其生存时间造成影响,也无法通过虚引用获得一个对象实例。 - 为对象设置虚引用关联的唯一目的是能在该对象被收集器回收时收到一个系统通知 ### 垃圾回收细节 即使在可达性分析算法中被判定为不可达,对象也并非一定会被回收。要真正宣告对象的死亡,必须经历两次标记过程: - 如果在可达性分析后发现GC Roots到该对象不可达,其将会被第一次标记并进行一次筛选,筛选条件是对该对象是否有必要执行finalize方法: - 如果当前对象没有覆盖finalize方法,或是finalize方法已经被虚拟机调用过,则虚拟机将其视为没有必要执行 - 如果当前对象被判定为需要执行finalize方法,那么该对象将会被放置到F-QUEUE的队列中,并且稍后由一个`由虚拟机自动建立的、优先级较低的Finalizer线程`去执行。该执行只是触发该方法,并不承诺会等待其执行结束。 - finalize方法是对象逃脱死亡命运的最后一次机会,稍后GC将会对F-QUEUE中的对象进行第二次小规模的标记,如果对象需要在finalize中避免自己被回收,只需将自身和引用链上任一对象建立关联即可(例如将自身赋值给引用链上任一对象的成员变量或类变量),那么在第二次标记时其将被移除即使回收的集合;如果此时对象仍未逃脱,其会被真正回收 即对象被GC第一次标记为不可达后,只能通过finalize方法来避免自己被回收,并且`finalize方法只会被调用一次`。需要覆盖finalize逻辑,在finalize方法中重新令自身变得可达,此时才能避免被垃圾回收。 > 如果一个对象已经调用过一次finalize,并且成功变为可达。那么其再次变为不可达后,将不会有再次调用finalize的机会,这次对象将会被回收。 通常,不推荐使用finalize方法。 ### 回收方法区 Java虚拟机规范并不强制要求对方法区进行垃圾回收,并且在方法区进行垃圾回收的性价比较低: - 在堆中,尤其在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,但方法区垃圾回收的效率远低于此 方法区的垃圾回收主要回收两部分内容: - 废弃常量 - 无用类 例如,在回收字面量时,如果一个字符串“abc"已进入常量池,但是当前系统没有任何一个String对象引用常量池中“abc”常量,也没有其他地方引用该字面量,如果此时发生垃圾回收,该常量将会被系统清理出常量池。常量池中其他方法、接口、字段、类的符号引用也与此类似。 ##### 判断类是否可回收 判断类是否可回收的条件比较苛刻,必须同时满足如下条件: - 该类所有实例已经被回收,java堆中不存在任何该类的实例 - 加载该类的classloader已经被回收 - 该类对应的java.lang.Class对象没有在其他任何地方被引用,无法在其他任何地方通过反射访问该类的方法 在大量使用反射、动态代理、Cglib、动态生成jsp、动态生成osgi等频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,已保证永久代不会溢出。