1 前言
在讲java创建之前,我们先来了解下Java虚拟机内存组成,当Java虚拟机启动后,会将系统分配给JVM的空间逻辑上划分为堆、虚拟机栈、本地方法栈、方法区、程序计数器五个部分,如下图所示:
堆:放置new出来的对象、数组
虚拟机栈:线程运行前,会给其分配一个线程栈空间,线程中每个方法执行都会生成一个栈帧放入线程栈中,栈帧里面包含局部变量表、操作数栈、动态连接和方法出口四部分。
局部变量表:存储方法中的局部变量
操作数栈:用于赋值或者计算的数据
动态链接:方法执行的入口地址
方法出口:返回调用方法的地址
本地方法栈:与虚拟机栈类似,是调用非java方法的栈
方法区:存储类元信息、常量池
程序计数器:指向线程正在运行的位置
2 Java对象创建
new一个对象的过程如上图所示,依次执行类加载检查、分配内存、初始化零值、设置对象头和执行clinit五步。上述五步的作用分别如下:
类加载检查:检查对象对应的class文件是否已被加载
分配内存:在堆上或栈上分配内存存储对象
初始化零值:将分配的内存赋零值
设置对象头:在对象头中设置对象运行相关信息、类指针、数组长度(是数组才有)
执行clinit:赋值并执行构造函数
下面我们来详细分析下每一步里面都分别做了什么。
2.1 类加载检查
创建一个对象之前,肯定需要知道该对象对应类的相关信息,比如内存要分配多少、对象属性赋值为多少。这些信息都存储在编译后的class文件中,所以首先需要将对象的class文件加载进JVM内存,当创建该类的对象时,需要什么信息就去对应内存中获取。把class文件加载进内存的过程叫做JVM类加载,其中就涉及两个问题,第一是谁来加载,第二是具体如何加载。下面我们就来理下这两个问题。
2.1.1 谁来加载
这就要从运行java程序开始讲了,现有MyMath.class,执行java MyMath后,大体过程如下图所示。
执行java MyMath后,java.exe会调用底层jvm.dll创建Java虚拟机和引导类加载器实例,然后底层C++代码会调用Java代码创建JVM启动器实例Launcher,其中会创建扩展类加载器和应用类加载器,在创建这两个类加载器时,会将扩展类加载器的父加载器赋值为引导类加载器(实际赋值为null,引导类加载器是在C++底层生成的,JVM里面获取不到),应用类加载器的父加载器赋值为扩展类加载器。之后会获取对应的类加载器去加载class文件,一般该类加载器为应用类加载器,也可自定义类加载器加载类。具体代码如下图所示:
目前可以看到,JVM启动后会产生中三个类加载器,分别是引导类加载器、扩展类加载器、应用类加载器,这三个类加载器作用为:
引导类加载器:加载/JAVA_HOME/bin目录下的类库
扩展类加载器:加载/JAVA_HOME/bin/ext目录下的类库
应用类加载器:加载类路径目录下的类库
可以看出这三个类加载器的分别加载不同的类库,为什么JVM这样设计呢?主要是基于安全的考虑,不允许随意修改核心类库,还有就是共用类库加载一次就行,无需多次加载。为了实现上述效果,JVM类加载器还设计了双亲委派机制,具体流程如下图所示:
当加载一个类时,应用类加载器会先判断该类是否已被加载,如被加载则返回。如未被加载,应用类加载器不会直接加载而是委托父加载器去加载,直到启动类加载器。当启动类加载器在其路径下未找到该类文件,则交由其子类加载器去目录下加载,直至加载成功。若最后应用类加载器在其目录下也为找到该类文件,则抛出异常。
各类加载器分别加载不同目录类库和双亲委派机制解决了安全和重复加载的问题,但是随着程序越来越复杂,会出现下面的场景,tomcat部署多个应用时,应用可能会使用同一个类库的不同版本。如果还是用上述三个类加载器和双亲委派机制,一个类只能加载一次,最后会导致应用不能正常使用。如果要满足,就需要自定义类加载器和打破双亲委派机制(不向上委派就算打破)。从双亲委派的代码可以看到,打破双亲委派机制需要重写loadClass(),自定义类加载器重写findClass()即可。Tomcat打破双亲委派的过程可以看下最后补充内容,这里就不详细讲了。找到了类被谁加载,下面就来讲讲具体加载过程。
2.1.2 如何加载
加载过程如下图所示:
JVM完整的类加载需要经历加载、验证、准备、解析、初始化、使用、卸载七个过程,其中验证、准备、解析又称为连接过程。这几个过程的作用分别如下:
加载:找到class文件,并将其转化为二进制字符流,加载进JVM虚拟机内存中,
验证:检查二机制字符流是否符合JVM规范
准备:给静态变量、常量分配内存,静态变量赋零值,常量直接赋值
解析:将符号引用转化为直接引用
初始化:给静态变量赋值
class文件加载进JVM内存后,类元信息放在方法区,会在堆内生成一个类元指针,指向方法区中的类元信息,是程序找到类信息的入口。目前为止,类已经加载进JVM对应内存了,那创建Java对象的第一步校验就通过了,下面就开始分配内存了。
2.2 分配内存
一般来说,对象的内存都会分配在堆上,但为了减少垃圾回收的压力,JVM中的实际分配内存如下图所示:
new一个对象时,会先判断是否能进行栈上分配,主要依赖于逃逸分析和标量替换,就是先判断该对象是否会逃逸出当前作用域,被其他对象引用,如果不会逃逸出当前作用域,就会考虑栈上分配,如果此时栈上剩余内存不够就在堆上分配。如果栈内存足够,但不连续,就会将对象进行标量替换,分解为不可再分的标量,将其放在栈上的各个地方,会标记哪些变量属于同一个对象。以上都是考虑到该对象不会逃逸,那就会随着出栈直接销毁,减少GC压力。但是不管是在栈上分配还是堆上分配内存,都涉及如何具体分配以及避免并发的问题。目前JVM有指针碰撞和空闲列表两种分配方式:
指针碰撞:内存分配规整,未分配内存和已分配内存中间有一个指针,该指针指向未分配内存地址
空闲列表:内存分配不规整,维护一个列表,存储空闲内存的地址
为解决分配过程中存在并发的问题,一般使用以下两种方式:
CAS+重试:通过该种方式将分配操作原子性
TLAB:采用这种方式时,线程启动时在堆上专门分配一块内存给线程存储对象
2.3 初始化零值
将分配给对象的空间用零值将之前的数据覆盖掉
2.4 设置对象头
对象由对象头、实例数据、对齐填充三部分组成的,前面已经将实例数据的内存空间赋为了零值,现在就剩下对象头了,对象头包含信息如下图所示:
对象头=markword+Kclass指针+数组长度,具体里面包含的信息如图上所示。其中注意Kclass指针是JVM虚拟机访问类元信息的入口。我们自己写的程序是无法使用到这个指针的。我们使用反射用到的类元指针,是加载类时生成的那个。可通过下面程序查看对象组成
```
package com.dailystudy.jvm;
import org.openjdk.jol.info.ClassLayout;
/***
* 计算对象大小
*/
public class JOLSample {
//运行需要加载jol-core.jar包
//-XX:+UseCompressedOops 默认开启指针压缩所有指针
//-XX:+UseCompressedClassPointers 默认开启的只压缩对象头里的类型指针Klass Pointer
//Ooops Ordinary Object Pointers
public static class A{
int id;
String name;
byte b;
Object o;
}
public static void main(String[] args) {
ClassLayout layout = ClassLayout.parseInstance(new Object());
System.out.println(layout.toPrintable());
System.out.println("-----------------------------");
ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
System.out.println(layout1.toPrintable());
System.out.println("-----------------------------");
ClassLayout layout2 = ClassLayout.parseInstance(new A());
System.out.println(layout2.toPrintable());
}
}
```
2.5 执行clinit
对对象静态数据进行赋值,并执行构造函数,至此,对象就生成完成了。后续程序就可以使用它进行相应操作,那对象无需使用的时候,如何销毁它回收之前分配的内存呢?
3 对象回收
我们都知道Java会帮我们自动回收不用的内存,而无需我们像C++一样手动释放,那JVM到底是怎么做的呢?我们先来了解下JVM内存回收大体机制,之前讲到JVM内存逻辑上会分为堆、虚拟机栈、本地方法栈、方法区、程序计数器五个部分,对于内存回收来说,只会回收堆和方法区,因为堆里面放置的是大量new出来的对象,当其不再使用时,就可以回收掉其内存了。方法区放的时大量的类元信息,当一个类无需使用的时候,也可将其进行卸载回收空间。JVM的设计者,基于经验即程序生成的对象总是朝生夕死,将堆内存分为了老年代和年轻代,其比例一般为1:2,根据各自的特点分别采用不同的垃圾回收算法。JVM堆内存回收规则大概如下图所示:
可以看到年轻代又分为了Eden区、S1区、S2区,其比例默认是8:1:1。最开始对象一般都放置在Eden或者S1区(大对象除外,大对象会直接放入老年代),当Eden和S1区放满了后,会触发MinorGC,该次GC会回收掉Eden和S1区的垃圾对象,将存活对象移动到S2区,并将其存活对象分代年龄加1。然后新进来的对象就都放置在Eden和S2区,当其满了触发MinorGC,也会跟着之前一样回收垃圾对象,将存活对象放置在S1区同时分代年龄加一。当分代年龄大于15时,会将其从年轻代赋值到老年代,当老年代放满后,会触发FullGC,清理年轻代、老年代、方法区的内存(其中还涉及对象分代年龄判断、老年代空间担保机制)。
触发MinorGC和FullGC会将所有用户线程暂停,即产生STW现象。一般MinorGC耗时较短,FullGC还是较长。当用户使用应用程序时,STW会让客户产生卡顿的感觉,对于实时性较高的系统,是无法忍受的。所以减少FullGC的次数,降低MinorGC频次就成为了JVM调优的重要目标。对于方法区的回收要求较高,我这边就简单列一下,一般来说类卸载不会经常发生。
该类所有的实例对象都已经被回收,也就是Java堆中不存在该类的任何实例
加载该类的ClassLoader已经被回收
该类对应的java.lang.Class对象没有在任何地方引用,无法在任何地方通过反射访问该类的方法
4 补充Tomcat打破双亲委派机制
tomcat是一个web容器,它需要解决什么问题呢?
1 一个web容器可能需要部署不同的应用,不同的应用可能会依赖同一个类库的不同版本,不能保证同一个类在同一个服务器中只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
2 部署在同一个web容器中相同的版本的类库,只需加载一份共享
3 web容器的类库与程序的类库隔离开来
4 web支持JSP热修改
第一个问题:双亲委派机制下同一个类只能加载一份,所以需要打破双亲委派,使用web类加载器加载自己所需的类库版本,无需向上委托。
第二个问题:默认的类加载器机制可以实现
第三个问题:与第一个问题一样
第四个问题:每个JSP文件就有一个类加载器,有一个线程,监听文件修改,然后清空当前类加载器,赋值新的classLoader
目前Tomcat的实现机制如下:
如上图,橙色部分还是和原来一样,采用双亲委派机制,黄色部分是tomcat第一部分自定义的类加载器,这部分主要加载tomcat包中的类,这一部分依然采用的是双亲委派机制,而绿色部分是tomcat第二部分自定义类加载器,正是这一部分,打破了类的双亲委派机制。
tomcat给每个web应用创建一个类加载实例WebAppClassLoader,这个类中重写了loadClass方法,让其先加载当前应用目录下的类,如果找不到才向上委托。对于多个WEB可以共用的类,就可以放在同一目录下,让SharedClassLoader去加载。