第3章 深入理解JVM
深入理解JVM
image.pngJVM标准结构
image.png
JVM负责装载class文件并执行,因此,首先要掌握的是JDK如何将Java代码编译为class文件、如何装载class文件及如何执行class,将源码编译为class文件的实现取决于各个JVM实现或各种源码编译器。class文件通常由类加载器(ClassLoader)来完成加载;class的执行在Sun JDK中有解释执行和编译为机器码执行两种方式,其中编译为机器码又分为client和server两种模式。Sun JDK为了提升class的执行效率,对于解释执行和编译为机器码执行都设置了很多的优化策略。
3.1 Java代码的执行机制
3.1 Java代码的执行机制
3.1.1 Java源码编译机制
image.pngjavac编译源码为class文件的步骤
- 分析和输入到符号表(Parse and Enter)
- 注解处理(Annotation Processing)
- 语义分析和生成class文件(Analyse and Generate)
3.1.2 类加载机制
类加载机制是指.class文件加载到JVM,并形成Class对象的机制
应用就可对Class对象进行实例化并调用,类加载机制可在运行时动态加载外部的类、远程网络下载过来的class文件等。除了该动态化的优点外,还可通过JVM的类加载机制来达到类隔离的效果.
JVM将类加载过程划分为三个步骤:装载、链接和初始化。
装载和链接过程完成后,即将二进制的字节码转换为Class对象;初始化过程不是加载类时必须触发的,但最迟必须在初次主动使用对象前执行,其所作的动作为给静态变量赋值、调用<clinit>()等。
1. 装载(Load)
装载过程负责找到二进制字节码并加载至JVM中,JVM通过类的全限定名(com.bluedavy. HelloWorld)及类加载器(ClassLoaderA实例)完成类的加载,同样,也采用以上两个元素来标识一个被加载了的类:类的全限定名+ClassLoader实例ID。类名的命名方式如下:
对于接口或非数组型的类,其名称即为类名,此种类型的类由所在的ClassLoader负责加载;
对于数组型的类,其名称为“[”+(基本类型或L+引用类型类名;),例如byte[] bytes=new byte[512],该bytes的类名为:[B; Object[] objects=new Object[10],objects的类名则为:[Ljava.lang.Object;,数组型类中的元素类型由所在的ClassLoader负责加载,但数组类则由JVM直接创建。
2. 链接(Link)
链接过程负责对二进制字节码的格式进行校验、初始化装载类中的静态变量及解析类中调用的接口、类。
二进制字节码的格式校验遵循Java Class File Format(具体请参见JVM规范)规范,如果格式不符合,则抛出VerifyError;校验过程中如果碰到要引用到其他的接口和类,也会进行加载;如果加载过程失败,则会抛出NoClassDefFoundError。
在完成了校验后,JVM初始化类中的静态变量,并将其值赋为默认值。
最后对类中的所有属性、方法进行验证,以确保其要调用的属性、方法存在,以及具备相应的权限(例如public、private域权限等)。如果这个阶段失败,可能会造成NoSuchMethodError、NoSuchFieldError等错误信息。
3. 初始化(Initialize)
初始化过程即执行类中的静态初始化代码、构造器代码及静态属性的初始化,在以下四种情况下初始化过程会被触发执行:
1)调用了new;
2)反射调用了类中的方法;
3)子类调用了初始化;
4)JVM启动过程中指定的初始化类。
在执行初始化过程之前,首先必须完成链接过程中的校验和准备阶段,解析阶段则不强制。
JVM的类加载通过ClassLoader及其子类来完成,分为
1、Bootstrap ClassLoader、
2、Extension ClassLoader、
3、System ClassLoader、
4、User-Defined ClassLoader。
这4种ClassLoader的关系如图3.4所示。
image.png
1、Bootstrap ClassLoader
Sun JDK采用C++实现了此类,此类并非ClassLoader的子类,在代码中没有办法拿到这个对象, Sun JDK启动时会初始化此ClassLoader,并由ClassLoader完成$JAVA_HOME中jre/lib/rt.jar里所有class文件的加载,jar中包含了Java规范定义的所有接口及实现。
2、 Extension ClassLoader
JVM用此ClassLoader来加载扩展功能的一些jar包,例如Sun JDK中目录下有dns工具jar包等,在Sun JDK中ClassLoader对应的类名为ExtClassLoader。
3.、System ClassLoader
JVM用此ClassLoader来加载启动参数中指定的Classpath中的jar包及目录,在Sun JDK中ClassLoader对应的类名为AppClassLoader。
4、User-Defined ClassLoader是Java开发人员继承ClassLoader抽象类自行实现的ClassLoader,基于自定义的ClassLoader可用于加载非Classpath中(例如从网络上下载的jar或二进制)的jar及目录、还可以在加载之前对class文件做一些动作,例如解密等。
ClassLoader抽象类提供了几个关键的方法:
● loadClass
此方法负责加载指定名字的类,ClassLoader的实现方法为先从已经加载的类中寻找,如没有,则继续从parent ClassLoader中寻找;如果仍然没找到,则从System ClassLoader中寻找,最后再调用findClass方法来寻找;如果要改变类的加载顺序,则可覆盖此方法;如果加载顺序相同,则可通过覆盖findClass来做特殊的处理,例如解密、固定路径寻找等。当通过整个寻找类的过程仍然未获取Class对象时,则抛出ClassNotFoundException。
如果类需要resolve,则调用resolveClass进行链接。● findLoadedClass
此方法负责从当前ClassLoader实例对象的缓存中寻找已加载的类,调用的为native的方法。
● findClass
此方法直接抛出ClassNotFoundException,因此要通过覆盖loadClass或此方法来以自定义的方>>式加载相应的类。
● findSystemClass
此方法负责从System ClassLoader中寻找类,如未找到,则继续从Bootstrap ClassLoader中寻>>找,如果仍然未找到,则返回null。
● defineClass
此方法负责将二进制的字节码转换为Class对象,这个方法对于自定义加载类而言非常重要。如果二进制的字节码的格式不符合JVM Class文件的格式,则抛出ClassFormatError;如果生成的类名和二进制字节码中的不同,则抛出NoClassDefFoundError;如果加载的class是受保护的、采用不同签名的,或者类名是以java.开头的,则抛出SecurityException;如果加载的class在此ClassLoader中已加载,则抛出LinkageError。
● resolveClass
此方法负责完成Class对象的链接,如果链接过,则会直接返回。
当Java开发人员调用Class.forName来获取一个对应名称的Class对象时,JVM会从方法栈上寻找第一个ClassLoader,通常也就是执行Class.forName所在类的ClassLoader,并使用此ClassLoader来加载此名称的类。JVM为了保护加载、执行的类的安全,它不允许ClassLoader直接卸载加载了的类,只有JVM才能卸载,在Sun JDK中,只有当ClassLoader对象没有引用时,此ClassLoader对象加载的类才会被卸载。
类加载常见异常
1. ClassNotFoundException
2. NoClassDefFoundError
3. LinkageError
该异常在自定义ClassLoader的情况下更容易出现,主要原因是此类已经在ClassLoader加载过了,重复地加载会造成该异常,因此要注意避免在并发的情况下出现这样的问题。
4. ClassCastException
该异常有多种原因,在JDK 5支持泛型后,合理使用泛型可相对减少此异常的触发。这些原因中比较难查的是两个A对象由不同的ClassLoader加载的情况,这时如果将其中某个A对象造型成另外一个A对象,也会报出ClassCastException。
线程在创建后,都会产生程序计数器(PC)(或称为PC registers)和栈(Stack);PC存放了下一条要执行的指令在方法内的偏移量;栈中存放了栈帧(StackFrame),每个方法每次调用都会产生栈帧。栈帧主要分为局部变量区和操作数栈两部分,局部变量区用于存放方法中的局部变量和参数,操作数栈中用于存放方法执行过程中产生的中间结果
Sun JDK基于栈的体系结构
-
指令解释执行
-
栈顶缓存
-
部分栈帧共享
3.2 JVM内存管理
3.2 JVM内存管理
Java不需要开发人员来显式分配内存和回收内存,而是由JVM来自动管理内存的分配及回收(又称为垃圾回收、Garbage Collection或GC),这对开发人员来说确实大大降低了编写程序的难度,但副作用可能是在不知不觉中浪费了很多内存,导致JVM花费很多时间进行内存的回收。另外还可能会带来的副作用是由于不清楚JVM内存的分配和回收机制,造成内存泄露,最终导致JVM内存不够用。
3.2.1 内存空间
image.png
JVM内存结构
程序计数器,栈 , 方法区, 本地方法栈,堆
JVM内存结构
类信息,静态变量,常量,方法信息,属性信息
方法区
方法区存放了要加载的类的信息(名称、修饰符等)、类中的静态变量、类中定义为final类型的常量、类中的Field信息、类中的方法信息,当开发人员在程序中通过Class对象的getName、isInterface等方法来获取信息时,这些数据都来源于方法区域。方法区域也是全局共享的,在一定条件下它也会被GC,当方法区域要使用的内存超过其允许的大小时,会抛出OutOfMemory的错误信息。
在Sun JDK中这块区域对应Permanet Generation,又称为持久代,默认最小值为16MB,最大值为64MB,可通过-XX:PermSize及-XX:MaxPermSize来指定最小值和最大值。
堆
堆用于存储对象实例及数组值,可以认为Java中所有通过new创建的对象的内存都在此分配,Heap中对象所占用的内存由GC进行回收,在32位操作系统上最大为2GB,在64位操作系统上则没有限制,其大小可通过-Xms和-Xmx来控制,-Xms为JVM启动时申请的最小Heap内存,默认为物理内存的1/64但小于1GB;-Xmx为JVM可申请的最大Heap内存,默认为物理内存的1/4但小于1GB,默认当空余堆内存小于40%时,JVM会增大Heap到-Xmx指定的大小,可通过-XX:MinHeapFreeRatio=来指定这个比例;当空余堆内存大于70%时,JVM会减小Heap的大小到-Xms指定的大小,可通过-XX:MaxHeapFreeRatio=来指定这个比例,对于运行系统而言,为避免在运行时频繁调整Heap的大小,通常将-Xms和-Xmx的值设成一样。
为了让内存回收更加高效,Sun JDK从1.2开始对堆采用了分代管理的方式,如图3.8所示。
- 新生代(New Generation)
大多数情况下Java程序中新建的对象都从新生代分配内存,新生代由Eden Space和两块相同大小的Survivor Space(通常又称为S0和S1或From和To)构成,可通过-Xmn参数来指定新生代的大小,也可通过-XX:SurvivorRatio来调整Eden Space及Survivor Space的大小。不同的GC方式会以不同的方式按此值来划分Eden Space和Survivor Space,有些GC方式还会根据运行状况来动态调整Eden、S0、S1的大小。- 旧生代(Old Generation或Tenuring Generation)
用于存放新生代中经过多次垃圾回收仍然存活的对象,例如缓存对象,新建的对象也有可能在旧生代上直接分配内存。主要有两种状况(由不同的GC实现来决定):
一种为大对象,可通过在启动参数上设置-XX:PretenureSizeThreshold=1024(单位为字节,默认值为0)来代表当对象超过多大时就不在新生代分配,而是直接在旧生代分配,此参数在新生代采用Parallel Scavenge GC时无效,Parallel Scavenge GC会根据运行状况决定什么对象直接在旧生代上分配内存;
另一种为大的数组对象,且数组中无引用外部对象。
旧生代所占用的内存大小为-Xmx对应的值减去-Xmn对应的值。
本地方法栈
本地方法栈用于支持native方法的执行,存储了每个native方法调用的状态,在Sun JDK的实现中本地方法栈和JVM方法栈是同一个。
PC寄存器和JVM方法栈
每个线程均会创建PC寄存器和JVM方法栈, PC寄存器占用的可能为CPU寄存器或操作系统内存,JVM方法栈占用的为操作系统内存,JVM方法栈为线程私有,其在内存分配上非常高效。当方法运行完毕时,其对应的栈帧所占用的内存也会自动释放。
当JVM方法栈空间不足时,会抛出StackOverflowError的错误,在Sun JDK中可以通过-Xss来指定其大小,例如如下代码:
new Thread(new Runnable(){
public void run() {
loop(0);
}
private void loop (int i){
if(i!=1000){
i++;
loop (i);
} else{
return;
}
} }).start();
当JVM参数设置为-Xss1K时,运行后报出类似下面的错误:
Exception in thread "Thread-0" java.lang.StackOverflowError
3.2.2 内存分配
Java对象所占用的内存主要从堆上进行分配,堆是所有线程共享的,因此在堆上分配内存时需要进行加锁,这导致了创建对象开销比较大。当堆上空间不足时,会触发GC,如果GC后空间仍然不足,则抛出OutOfMemory错误信息。
Sun JDK为了提升内存分配的效率,会为每个新创建的线程在新生代的Eden Space上分配一块独立的空间,这块空间称为TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行情况计算而得,可通过-XX:TLABWasteTargetPercent来设置TLAB可占用的Eden Space的百分比,默认值为1%。JVM将根据这个比率、线程数量及线程是否频繁分配对象来给每个线程分配合适大小的TLAB空间。在TLAB上分配内存时不需要加锁,因此JVM在给线程中的对象分配内存时会尽量在TLAB上分配,如果对象过大或TLAB空间已用完,则仍然在堆上进行分配。因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效,可通过在启动参数上增加-XX:+PrintTLAB来查看TLAB空间的使用情况。
在分配细节上取决于GC的实现,后续GC的实现章节会继续介绍。
除了从堆上分配及从TLAB上分配外,还有一种是基于逃逸分析直接在栈上进行分配的方式,此方式已在前文中提及。
3.2.3 内存回收
收集器
JVM通过GC来回收堆和方法区中的内存,GC的基本原理首先会找到程序中不再被使用的对象,然后回收这些对象所占用的内存,通常采用收集器的方式实现GC,主要的收集器有引用计数收集器和跟踪收集器。
网友评论