Java对象在JVM中如何分配?分配在哪个地方?Java有自动内存管理机制,那它是怎么判定一个对象是垃圾对象的?以及怎样对垃圾对象回收?
这篇文章就聊聊这些关于Java对象生与死相关的事。
一、模板 — 类加载
1.1 类加载过程
当JVM创建一个对象时,首先会判断该对象的类是否已加载,如果没有加载,则进行类加载。类加载会将Class文件(或者流)加载到内存,对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
类加载主要包括五个步骤:
- 1)加载。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。
- 2)验证。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。包括四个部分的验证:文件格式的验证、元数据的验证、字节码的验证以及符号引用的验证。
- 3)准备。正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中。
- 4)解析。解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。
- 5)初始化。初始化阶段就是执行类构造器<clinit>()方法的过程。<clinit>()并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物。<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
1.2 双亲委派模型
类加载器加载类,一般情况下,都会遵循双亲委派模型。那么啥是双亲委派模型?为什么要设计双亲委派模型?
双亲委派模型指的是:类加载器之间存在父子关系,但是这种父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
设计双亲委派模型有两个原因:
- 1)沙箱安全机制:自己写的java.lang.String.class类不会被加载,这样便可以防止核心API库被随意篡改
- 2)避免类的重复加载:当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次,保证被加载类的唯一性
这样能够保证:
- 1)放在<java_home>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的类库只被启动类加载器(Bootstrap Class Loader)加载。
- 2)<java_home>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库只被扩展类加载器(Extension Class Loader)加载。
- 3)用户类路径(ClassPath)上所有的类库则由AppClassLoader加载。
1.3 Tomcat打破双亲委派机制
双亲委派模型能够适用于所有应用场景吗?显然不行。
在Tomcat里,一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。另外,web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情, web容器需要支持 jsp 修改后不用重启。
这些功能显然是双亲委派机制所不支持的,为了支持这些功能,Tomcat设计了自己的类加载器体系。
WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见,比如加载war包里相关的类,每个war包应用都有自己的WebappClassLoader,实现相互隔离,比如不同war包应用引入了不同的spring版本,这样实现就能加载各自的spring版本。
JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的热加载功能。
这里着重讲讲Tomcat 的自定义类加载器 WebAppClassLoader是怎么打破双亲委派机制,实现多版本共存的。核心是重写 ClassLoader 的两个方法:findClass 和 loadClass。
先来看看loadClass:
- 1)先在本地 Cache 查找该类是否已经加载过,也就是说 Tomcat 的类加载器是否已经加载过这个类。
- 2)如果 Tomcat 类加载器没有加载过这个类,再看看系统类加载器是否加载过。
- 3)如果都没有,就让 ExtClassLoader 去加载,这一步比较关键,目的防止 Web 应用自己的类覆盖 JRE 的核心类。因为 Tomcat 需要打破双亲委托机制,假如 Web 应用里自定义了一个叫 Object 的类,如果先加载这个 Object 类,就会覆盖 JRE 里面的那个 Object 类,这就是为什么 Tomcat 的类加载器会优先尝试用 ExtClassLoader 去加载,因为 ExtClassLoader 会委托给 BootstrapClassLoader 去加载,BootstrapClassLoader 发现自己已经加载了 Object 类,直接返回给 Tomcat 的类加载器,这样 Tomcat 的类加载器就不会去加载 Web 应用下的 Object 类了,也就避免了覆盖 JRE 核心类的问题。
- 4)如果 ExtClassLoader 加载器加载失败,也就是说 JRE 核心类中没有这类,那么就在本地 Web 应用目录下查找并加载。
- 5)如果本地目录下没有这个类,说明不是 Web 应用自己定义的类,那么由系统类加载器去加载。这里请你注意,Web 应用是通过Class.forName调用交给系统类加载器的,因为Class.forName的默认加载器就是系统类加载器。
- 6)如果上述加载过程全部失败,抛出 ClassNotFound 异常。
再来看看findClass:
- 1)先在 Web 应用本地目录下查找要加载的类。
- 2)如果没有找到,交给父加载器去查找,它的父加载器就是上面提到的系统类加载器 AppClassLoader。
- 3)如果父加载器也没找到这个类,抛出 ClassNotFound 异常。
二、分配内存
有了模板,就可以创建对象了。那在哪里创建对象呢?所以首先就要解决内存分配的问题。
2.1 栈上分配
为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存。栈上分配依赖于逃逸分析和标量替换。
通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。
2.2 对象优先在Eden分配
分配内存有两种方式:
- 指针碰撞:如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
- 空闲列表:如果Java堆中的内存并不是规整的,已使用的内存和空 闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记 录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录
分配内存必然会遇到线程安全的问题,怎么解决?
- CAS加上失败重试的方式
- 按照线程划分在不同的空间之中进行,也即TLAB:本地线程分配缓冲(Thread Local Allocation Buffer)
2.3 大对象直接进入老年代
为了避免为大对象分配内存时的复制操作而降低效率。
三、对象数据填充
3.1 赋0值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
3.2 对象头设置
对象整体而言包括三部分:对象头、实例数据、对其填充。
对象头主要包括两部分(数组还有第三部分:数组长度)
- Mark Word:用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
- 类型指针(Klass元空间而非Class堆):即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
这里为了节省空间,64位JVM默认开启了指针压缩。可以通过对对象指针的存入堆内存时压缩编码、取出到cpu寄存器后解码方式进行优化(对象指针在堆中是32位,在寄存器中是35位,2的35次方=32G),使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)。
四、对象的使用
对象一般都是在某个方法中被使用,其实就是Execution engine(执行引擎)执行classes中指令时使用。
当使用该对象的方法调用完成后,该方法的栈帧就会被销毁,那么就有可能导致该对象称为垃圾对象。
五、垃圾对象的判定与回收
5.1 对象已死?
怎样判定对象是垃圾对象,一般有两种方法,JVM使用可达性分析算法。
- 1)引用计数法:单纯的引用计数就很难解决对象之间相互循环引用的问题。
- 2)可达性分析算法:通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
5.2 垃圾收集算法
主要有三种垃圾收集算法:
- 1)标记-清除算法。分为两个阶段:标记存活的对象,统一回收所有未被标记的对象。缺点:执行效率不稳定,标记和清除两个过程的执行效率都随对象数量增长而降低;内存空间的碎片化问题
- 2)标记-复制算法。将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。缺点是空间浪费。改进:把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor
- 3)标记-整理算法(能增加吞吐量):其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
网友评论