[关闭]
@Catyee 2021-08-12T02:42:58.000000Z 字数 30015 阅读 318

Java虚拟机(JVM)

面试


Java虚拟机(JVM)是Java平台的重要组成部分,只要符合Java虚拟机规范,任何人都可以开发出自己的Java虚拟机。不过现在大部分Java程序员已经在潜意识地把Java虚拟机与OracleJDK的HotSpot虚拟机等同看待,所以下文的所有描述都针对普遍使用的HotSpot虚拟机。

一、Java语言的平台无关性与JVM的语言无关性

我们写完程序之后,需要用java编译器(javac等)将源程序(.java文件)编译成JVM可以理解的字节码(.class文件),然后JVM通过不同平台的不同解释器生成与平台相对应的机器码来执行Java程序,这就是所谓的前端编译与后端编译,前端编译只要一次,后端编译则在不同平台由不同解释器完成,即实现了所谓的“一次编译,处处运行”。

从java虚拟机的角度来说,任何Java虚拟机的输入、输出都是一致的:输入的是字节码二进制流,输出的是最终的执行结果。所以java虚拟机并没有与某种具体的语言绑定,只要这种语言能够翻译成符合规则的字节码,JVM都可以执行,所以JVM本身是语言无关的。

JVM结构:JVM由类加载器子系统、运行时数据区和执行引擎构成

二、类生命周期

一个类从加载到虚拟机内存开始,到卸载出虚拟机内存,一共要经历5个阶段:
加载、连接(连接又分为验证、准备、解析三个阶段)、初始化、使用和卸载
注意:解析阶段有可能在初始化阶段之后再开始。这是为了支持Java语言的运行时绑定特性。

2.1 类加载过程

类加载过程即指类生命周期中的加载、连接、初始化三个阶段。

1 加载阶段

一句话概括:将.class文件从磁盘读到内存
1、根据类全限定名获取二进制字节流
2、将字节流所代表的静态存储结构转化为方法区的运行时数据结构
3、生成代表这个类的java.lang.Class对象,作为方法区中这个类的各种数据的访问入口
加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了。
另外要注意加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始。

2 连接阶段

连接分为验证、准备、解析三个阶段。
a、验证阶段:
一句话概括:验证输入的字节流的合法性
大致上会进行四个阶段的检验动作:
文件格式验证、元数据验证、字节码验证、符号引用验证
b、准备阶段:
一句话概括:给类的静态变量分配内存,并赋予默认值
这个阶段是正式为类中定义的静态变量(类变量)分配内存并设置类变量初始值的阶段,这里所说的初始值“通常情况”下是数据类型的零值。假设一个类变量的定义为:
public static int value = 123;
那变量value在准备阶段过后的初始值为0而不是123,因为这时尚未开始执行任何Java方法,而把 value赋值为123的putstatic指令是程序被编译后,存放于类构造器< clinit>()方法之中,所以把value赋值 为123的动作要到类的初始化阶段才会被执行。
假设上面类变量value的定义修改为:
public static final int value = 123;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据Con-stantValue的设置 将value赋值为123。
c、解析阶段:
一句话概括:类加载器装入当前类所引用的其它所有类
这个阶段是Java虚拟机将class字节流的常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。

3 类初始化阶段

一句话概括:为类的静态变量赋予正确的初始值;如果有静态代码块,还会执行静态代码块
上面所说的准备阶段为静态变量赋予的是虚拟机默认的初始值,此处赋予的才是程序编写者为变量分配的真正的初始值。
这个阶段就是执行类构造器< clinit>()方法的过程。进行准备阶段的时候变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源,即执行类构造器< clinit>()方法的过程。< clinit>()并不是程序员在Java代码中直接编写 的方法,它是Javac编译器的自动生成物,< clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对静态变量的 赋值操作,那么编译器可以不为这个类生成< clinit>()方法。

2.2 类加载器与类加载机制

1 类加载器

对于任意一个类,都必须由加载它的类加载器和这个类的全限定名共同确立这个类在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,在同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。这里所指的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括了使用instanceof关键字做对象所属关系判定等各种情况。

启动类加载器(BootstrapClassLoader):负责加载jre的核心类库,即存放在jre的lib目录或者被-Xbootclasspath参数所指定的路径中存放jar包
扩展类加载器(ExtensionClassLoader):负责加载JRE扩展目录ext中或者被java.ext.dirs系统变量所指定的路径中的jar类包
系统类加载器(ApplicationClassLoader):它负责加载用户类路径(ClassPath)上所有的类库。

2 类加载机制

双亲委托机制:
指当一个类加载器收到类加载请求后,总是先委托父类加载器去尝试加载这个类,只有在父类找不到的情况下才自己尝试加载。
这样做的好处有两点:
• 沙箱安全机制:比如自己写的String.class类不会被加载,这样可以防止核心库被随意篡改
• 避免类的重复加载:当父ClassLoader已经加载了该类的时候,就不需要子ClassLoader再加载一次

3 Class.forName()和ClassLoader的loadClass()方法

ClassLoader的loaderClass方法即采用了双亲委托机制来加载类,但是这种方式加载的类是不会进行初始化的,也就是说如果类中有静态代码块,通过这种方式加载的时候,静态代码块中的方法不会执行。只有在用newInstance创建一个对象的时候才会完成类的初始化。
Class.forName()有两个重载的方法,他们最终都是调一个native方法(forName0方法)去完成加载。可以看到第一个方法,只需要指定类的全限定名,Class.forName会使用当前的ClassLoader,并且在加载指定类的时候还会进行类的初始化。第二个方法有三个参数,除了类的全限定名,还可以指定类加载器和是否初始化。

  1. public static Class<?> forName(String className) {
  2. Class<?> caller = Reflection.getCallerClass();
  3. return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
  4. }
  5. public static Class<?> forName(String name, boolean initialize, ClassLoader loader) {
  6. ...
  7. }

4 类加载隔离

为什么要用类加载隔离?
想像一下加载数据库驱动的场景,假如这个驱动就在ClassPath路径下,DriverManager静态代码块会自动注册这个驱动,所谓注册实际上就是生成这个驱动的一个Driver实例放入到DriverManager中维护的一个List,DriverManger在getConnection的时候会从这个List中去取出这个实例。所以这种情况下直接可以通过DriverManager的getConnection获取连接了。

  1. // 这个List用于存放已经加载好的驱动
  2. // DriverInfo中只有两个属性,一个是具体的Driver实例,用于获取jdbc连接和执行sql语句
  3. // 另一个是卸载这个Driver的实例(DriverAction对象) ,卸载的逻辑需要用户自己实现,不指定默认为null
  4. private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();
  5. ...
  6. // 用于加载ClassPath下能找到的驱动
  7. // 实际上就是寻找实现了Driver接口的类,如果找到了就生成这个类的一个实例,并放入到上面的List
  8. static {
  9. loadInitialDrivers();
  10. println("JDBC DriverManager initialized");
  11. }

但是假如现在系统已经运行起来了,这个驱动是用户从系统界面上传的,不在ClassPath路径下。一般这种情况,我们可以使用URLClassloader去加载这个jar包,然后通过DriverManager的registDriver方法去注册驱动,注册驱动实际上就是将Driver对象放入到DriverManager中维护的List中去,这样调用DriverManger的getConnection就可以获取连接了。
URLClassloader也是采用的双亲委托机制,如果原来JVM中就有要加载的类,URLClassloader的父加载器可以加载到,那就不会去加载jar包中的类,比如原来JVM中有mysql5.1的驱动,用户要连接的是mysql8,上传的也是mysql8的驱动,此时URLClassloader并不会去加载mysql8的驱动,而是仍然用mysql5.1的驱动,那之后JDBC操作mysql8的时候就有可能会有问题。
还是这个例子,假如一开始jvm中没有任何mysql的驱动,URLClassloader就会加载用户上传的Mysql8的驱动,,假如这个驱动依赖了第三方包,他自己本身是一个fatJar,也就是说这个第三方包本身也在jar中,按正常情况URLClassLoader加载驱动的时候也会把这个第三方包中关联的类加载进来,但是刚好系统也依赖这个包,已经加载到了JVM。由于采用的是双亲委托机制,URLClassloader在加载这个第三方包的时候,发现已经加载过了,就不会去加载它,问题就来了,如果驱动依赖的第三方包和系统依赖的第三方包版本不一样,并且不兼容,那使用过程中就会报错。

实现类加载隔离的方式:
基于以上原因,在某些特定场景下是需要实现类加载隔离的,而之所以能进行类加载隔离,原因在于jvm中classloader加上类全限定名才能唯一判别一个类,也就是说就算全限定名一样,加载它的类加载器不一样(同一个类的不同实例即可)也会被JVM判定为不同的类。同时JVM加载一个类的时候,与这个类相关连的类也会被加载
基于以上两点,可以总结出类加载隔离的核心就在于:
1)使用不同于系统类加载器的自定义类加载器
2)加载的类和相关连的类总是被同一个自定义的类加载器所加载,这需要我们自定义类加载器的时候破坏双亲委托机制,否则就有可能会使用到父加载器已经加载出来的类

类加载隔离需要注意的点:
1)不同的类加载器加载相同的一个类,会对instanceof关键字、equals方法等造成一定的影响。有些时候赋值语句也会出现影响,比如=号左边使用的是appclassloader加载的类,右边是自定义类加载器加载的类,两边类加载器不一样可能被判定为无法赋值。解决方式就是动态代理。
2)如果用自定义的类加载器,一定要特别小心类的生命周期,如果类无法卸载,而且一直在用类加载器加载有可能造成元空间内存持续增长

2.3 类的卸载

类卸载的条件非常苛刻,需要同时满足下面三个条件:

可以看到第一个条件还是很容易达成的,关键在于如何达成后两个条件,也就是类加载器和Class对象在什么时候回收,而要想搞清楚类加载器和Class对象什么时候被回收掉,首先要搞清楚类加载和Class对象会被谁引用。

我们知道Object类中有一个默认的getClass()方法(是一个native方法),任何对象随时都可以调用这个方法来获得这个类的Class对象, 也就是说任何一个类的实例都引用了这个类的Class对象(这个引用关系比较难以理解,可以参考:https://www.cnblogs.com/xy-nb/p/6769586.html),这也是为什么一定要回收掉所有实例的原因,因为不回收掉所有实例,Class对象就无法回收,Class对象无法回收类就无法卸载。
此外,所有的类还都有一个静态属性class,它引用了代表这个类的Class对象(比如Sample.class,这个引用指向Sample类的Class对象,)。

我们还知道在类加载器的内部会用一个Vector来存放它所加载类的Class对象,如下,这段代码可以在ClassLoader类中找到:

  1. // The classes loaded by this class loader. The only purpose of this table
  2. // is to keep the classes from being GC'ed until the loader is GC'ed.
  3. private final Vector<Class<?>> classes = new Vector<>();

另一方面,一个Class对象也会引用它的类加载器,我们可以调用Class对象的getClassLoader()方法来获得它的类加载器,所以Class对象和类加载器是双向引用关系,如果使用引用计数法,那Class对象和类加载器都将不可能被垃圾回收掉,因为他们互相引用,但是如果使用可达性分析,他们在都不可达时就会被回收掉。(观察ClassLoader回收过程:https://blog.csdn.net/m0_37962779/article/details/78279721

由于Java虚拟机始终会引用Java虚拟机自带的类加载器(BootstrapClassLoader、ExtraClassLoader、AppClassLoader),这些类加载器所加载的类,在虚拟机的生命周期中,始终不会被卸载。
但如果是我们自定义的ClassLoader,只要设计和使用得当,是有可能被回收掉的,自定义ClassLoader加载的类就有可能卸载。如下图:
class对象与classloader的引用关系
如果上图左侧三个变量都为null,此时Sample对象、ClassLoader对象、Sample类的Class对象在垃圾回收的时候都被回收掉,则Sample类在方法区内的二进制数据就会被卸载。

三、对象的内存布局

对象在堆内存中的存储布局可以划分为三个部分:对象头、实例数据和对齐填充

3.1 对象头

HotSpot虚拟机中普通对象的对象头有两类信息:Mark Word和klass pointer。如下:

  1. |--------------------------------------------------------------|
  2. | Object Header (128 bits) |
  3. |------------------------------------|-------------------------|
  4. | Mark Word (64 bits) | Klass pointer (64 bits) |
  5. |------------------------------------|-------------------------|

数组对象中还会有额外的一块内存来记录数组的长度,原因是虚拟机可以通过普通Java对象的元数据信息计算Java对象的大小,但是对于数组对象,如果数组的长度是不确定的,将无法通过元数据中的信息计算出数组的大小(数组中一个元素的大小是可以计算的,但是长度不知道就不知道数组对象的总大小):

  1. |---------------------------------------------------------------------------------|
  2. | Object Header (160 bits) |
  3. |--------------------------------|-----------------------|------------------------|
  4. | Mark Word(64bits) | Klass pointer(64bits) | array length(32bits) |
  5. |--------------------------------|-----------------------|------------------------|

mark word中存储了对象自身运行时的一些数据,比如hashcode、GC分代年龄、锁状态标志、偏向线程ID等,要注意的是markword存储格式是不固定的,根据锁状态的不同存储的内容也不同,mark word在32位机器上是32bit(4字节),在64位机器上是64bit(8字节),下图为32位机器上对象的MarkWord:
32位机器上对象的MarkWord

klass pointer即类型指针,是对象指向它的类型元数据的指针,在32位机器上是32bit(4字节),在64位机器上没有压缩的情况下是64bit(8字节),压缩的情况下是32bit(4字节),jdk1.8是默认压缩的。
相关jvm参数:

  1. UseCompressedOops:普通对象指针压缩(oopsordinary object pointers的缩写),默认还会开启klass指针的压缩(UseCompressedClassPointers可单独控制klass指针的压缩,但是要和UseCompressedOops配合使用)

3.1.1 OOP-Klass二分模型

HotSpot没有将Java对象直接通过虚拟机映射到C++对象,而是设计了一个Oop-Klass模型,其中oop为Ordinary Object Pointer,用来表示对象的实例信息;klass用来表示类的元数据信息

每创建一个Java对象,在JVM内部也会相应创建一个OOP对象来表示这个Java对象实例。OOP类的共同基类是oopDesc,oopDesc类中包含两个数据成员:_mark和_metadata。其中markOop类型的_mark对象指的就是前面提到的的Mark World;而_metadata是一个联合体,其中_klass是普通指针,_compressed_klass是压缩类指针,它们就是前面讲到的元数据指针,这两个指针都指向instanceKlass对象,它用来描述对象的具体类型。

  1. //hotspot/src/share/vm/oops/oop.hpp
  2. class oopDesc {
  3. //....
  4. private:
  5. volatile markOop _mark;
  6. union _metadata {
  7. Klass* _klass;
  8. narrowKlass _compressed_klass;
  9. } _metadata;
  10. //....
  11. }

oopDesc类有两个子类:instanceOopDesc和arrayOopDesc,实际上就是普通对象和数组对象的对象头。

klass描述类在jvm层面的元信息,以及与其他类之间的关系:

  1. class Klass : public Metadata {
  2. friend class VMStructs;
  3. protected:
  4. // note: put frequently-used fields together at start of klass structure
  5. // for better cache behavior (may not make much of a difference but sure won't hurt)
  6. enum { _primary_super_limit = 8 };
  7. jint _layout_helper;
  8. juint _super_check_offset;
  9. Symbol* _name;
  10. Klass* _secondary_super_cache;
  11. // Array of all secondary supertypes
  12. Array<Klass*>* _secondary_supers;
  13. // Ordered list of all primary supertypes
  14. Klass* _primary_supers[_primary_super_limit];
  15. // java/lang/Class instance mirroring this class
  16. oop _java_mirror;
  17. // Superclass
  18. Klass* _super;
  19. // First subclass (NULL if none); _subklass->next_sibling() is next one
  20. Klass* _subklass;
  21. // Sibling link (or NULL); links all subklasses of a klass
  22. Klass* _next_sibling;
  23. Klass* _next_link;
  24. // The VM's representation of the ClassLoader used to load this class.
  25. // Provide access the corresponding instance java.lang.ClassLoader.
  26. ClassLoaderData* _class_loader_data;
  27. jint _modifier_flags; // Processed access flags, for use by Class.getModifiers.
  28. AccessFlags _access_flags; // Access flags. The class/interface distinction is stored here.
  29. // Biased locking implementation and statistics
  30. // (the 64-bit chunk goes first, to avoid some fragmentation)
  31. jlong _last_biased_lock_bulk_revocation_time;
  32. markOop _prototype_header; // Used when biased locking is both enabled and disabled for this type
  33. jint _biased_lock_revocation_count;
  34. TRACE_DEFINE_KLASS_TRACE_ID;
  35. // Remembered sets support for the oops in the klasses.
  36. jbyte _modified_oops; // Card Table Equivalent (YC/CMS support)
  37. jbyte _accumulated_modified_oops; // Mod Union Equivalent (CMS support)
  38. ....
  39. }

其中一些重要的属性:
_name:表示类名
_java_mirror:表示Klass的Java层镜像类(注释中已经写的很清楚了,就是Class对象
_super:表示父类
_subklass:表示第一个子类
next_slibling:指向的是下一个兄弟节点,JVM通过_subklass->next_slibling可以找到下一个子类。

instanceKlass是klass的子类,在HotSpot中,每一个已加载的Java类都会创建一个instanceKlass对象,instanceKlass对象的所有成员包含了JVM内部运行一个Java类所需的所有信息,这些成员变量在类解析阶段完成赋值。

klass和Class的区别:
instanceKlass是c++层(jvm层)对类信息的描述,主要用于jvm访问和操作类信息;而Class对象是java语言层面对类信息的描述,用于开发者访问和操作类信息。创建对象的时候会在对象头中保存instanceKlass对象的指针,通过对象头即可访问instanceKlass对象,也就是jvm层面的类信息,然后通过instanceKlass对象中保存的Class对象指针就可以访问到Class对象,也就是java层面的类信息,所以不要把klass理解为了Class,Class对象是一个普通java对象,保存在堆内存中,instanceKlass是c++对象,保存在方法区中,instanceKlass保存了Class对象的指针

3.1.1 对象的访问定位

Java程序会通过栈上的reference数据(引用)来操作堆上的具体对象。但是java虚拟机规范只规定reference是一个指向对象的引用,但是没有规定具体的实现方式,所以如何访问对象是虚拟机决定的,有两种主流的方式:

前面讲到HotSpot中java对象的对象头的时候,其中一部分就是klass指针,这个指针就是指向类型信息的指针,所以很明显HotSpot用的是直接指针法,reference保存的是对象的直接指针,也就是oops,64位机器压缩之后占用4字节,不压缩则是8字节

HotSpot在栈帧中通过reference(引用)访问一个对象的类信息的过程:
4、通过引用访问对象和类信息的过程

3.2 实例数据

接下来实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。

3.3 对齐填充

对象的第三部分是对齐填充,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数,因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

3.4 如何计算一个java对象的大小

假设是一个普通java对象,并且是64位机器,没有开启指针压缩,那么对象头的大小是128bit,也就是16字节,这一部分是很容易计算出来的。那么计算整个java对象的大小就需要知道实例数据的大小,所谓实例数据就是类的属性,所以我们要先知道java中各种数据类型的大小:

数据类型 所占空间(byte)
byte 1
short 2
int 4
long 8
float 4
double 8
char 1
reference 64位机器未压缩是8字节,压缩之后是4字节
boolean 1

注意boolean的大小取决于jvm的实现,java规范中没有规定boolean的大小,在HotSpot虚拟机中boolean占用一个字节(参考文章:java中boolean类型基础数据在内存中占用的空间大小分析

那么根据这张表和类属性然后就是对齐填充的部分计算出对象的大小了。比如这样一个类:

  1. private class ObjA {
  2. String str;
  3. byte b1
  4. int id;
  5. Object obj;
  6. }

那么在64位未压缩的情况下大小就是:
Size(objA) = 8(markword) + 8(kclass) + 4(String) + 1(byte) + 4(int) + 4(Object) + 3(填充) = 32byte

数组对象还要加上额外记录数组长度的4字节:
比如Size(new long(5)) = 8(markword) + 8(kclass) + 4(length) + 8*5 + 4(对齐) = 64byte
比如Size(new Long(5)) = 8(markword) + 8(kclass) + 4(length) + 4*5 = 40byte

一个空对象:new Object() 开启压缩:8(markword) + 4(kclass) + 4(对齐) = 16byte
没压缩:8(markword) + 8(kclass) = 16byte

3.5 创建一个对象的过程

1、类加载:当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
2、分配内存:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定。分配内存有指针碰撞和空闲列表两种分配方式。
3、默认零值:内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
4、对象头设置:Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。
5、对象初始化:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始。类的构造函数,即.class文件中的< init>()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照程序员预定的意图构造好。所以这个阶段就是通过构造器来进行对象初始化的阶段,这个阶段执行完一个对象就准备好被使用了。

四、运行时数据区

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。下图为java运行时数据区:
运行时数据区

4.1 程序计数器

程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一个线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

4.2 Java虚拟机栈

Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和return Address类型(指向了一条字节码指令的地址)
在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

4.3 本地方法栈

本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机
栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

4.4 JAVA堆

Java堆是被所有线程共享的一块内存区域,虚拟机启动时会自动分配创建,用于存放对象的实例,几乎所有对象都在堆上分配内存,当对象无法在该空间申请到内存将抛出OutOfMemoryError异常。堆也是垃圾收集器管理的主要区域。
新生代(Young Generation)
新生代分为两部分:伊甸区(Eden区)和幸存者区(Survivor区),所有的对象都是在eden区创建出来并分配内存,当回收内存的时候,会将存活的对象复制到Survivor区。当eden区内存用完或者其它原因导致无法创建新的对象的时候,就会发生minor gc,这时会将存活的对象从eden区和Survivor的from区复制到Survivor的to区,然后清空eden区和Survivor的from区。下次Minor GC的时候Survivor的from区和to区就会转变角色。
新生代的对象具有朝生夕死的特性,大部分对象都在发生minor gc的时候被回收掉了,存活的只是少部分,所以eden区和Survivor区并不需要1:1的比例,实际上默认的比例是8:1:1
老年代(Old Generation)
新生代每发生一次GC,如果对象没有被回收掉,其年龄就会增加一岁,当它的年龄超过了阈值,就会被移动到老年代。默认的阈值是15(对象头中记录分代年龄的区域只有4bit,二进制中4bit最大数就是15(1111))。如果老年代也满了就会发生Major GC(也可以叫Full GC),进行老年区的内存清理。如果老年代执行了Full GC之后发现依然无法进行对象的保存,就会抛出OOM异常。

4.5 方法区

方法区与Java堆一样,是各个线程共享的内存区域。它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据,类的所有字段和方法字节码,以及一些特殊方法如构造函数,接口代码也在这里定义。简单来说,所有定义的方法的信息都保存在该区域,静态变量+常量+类信息(构造方法/接口定义)+运行时常量池都存在方法区中。

方法区的内存回收主要是针对常量池的回收和类的卸载
根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

在HotSpot虚拟机中,1.7及之前对方法区的实现叫永久代,使用的是分配给JVM的内存。1.8之后对方法区的实现叫元空间,使用的是本地内存。要注意区分永久代、元空间和方法区的概念,方法区是Java虚拟机的规范,也就是任何种类的Java虚拟机都要有方法区,但至于怎么实现由虚拟机自己控制。而永久代和元空间都是HotSpot虚拟机对方法区的具体实现。之前的永久代也并不在Java堆中(物理意义上),只是使用了和Java堆一样的实现逻辑,使得HotSpot的垃圾收集器能够像管理Java堆一样管理方法区。

那之后为什么会移除永久代改用元空间呢?最直观的影响就是以前的JAVA程序更容易遇到永久代的内存溢出的问题(永久代有-XX:MaxPermSize的上限,即使不设置也有默认大小)而元空间改用本地内存之后只要没有达到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题。

默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来印象元空间的大小:
-XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
-XX:MaxMetaspaceSize,最大空间限制,默认是没有限制的,设置之后才会限制。改用元空间之后,每个类加载器都有专门的存储空间,用于记录这个类加载器所加载的类,类及相关的元数据的生命周期将与它的类加载器保持一致,类加载器被回收掉了,这个加载器所加载的所有类也都会卸载。

4.6 java中的常量池

java中有几种不同的常量池:class常量池、运行时常量池和字符串常量池。

4.6.1 class常量池

我们写的每一个Java类被编译后,就会形成一份class文件;class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References),每个class文件都有一个class常量池。

其中字面量包括:1、文本字符串 2、八种基本类型的值 3、被声明为final的常量等;
符号引用包括:1、类和方法的全限定名 2、属性的名称和描述符 3、方法的名称和描述符。

4.6.2 运行时常量池

运行时常量池存在于内存中,也就是class常量池被加载到内存之后的版本,是方法区的一部分。不同之处是:它的字面量可以动态的添加(String类的intern()),符号引用可以被解析为直接引用。

JVM在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在解析阶段,会把符号引用替换为直接引用,解析的过程会去查询字符串常量池,也就是我们下面要说的StringTable,以保证运行时常量池所引用的字符串与字符串常量池中是一致的。

运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

4.6.3 字符串常量池

从JDK1.7开始,原本存放在永久代的字符串常量池被移至Java堆之中,字符串常量池中实际上是字符串的引用,真正的String对象还是保存在堆中
字符串常量池底层是一个HashTable,保存的是key-value键值对,Java中的字符串采用了延迟加载的机制,当程序运行到具体某一行的时候如果有字符串,才会将这个字符串放入到字符串常量池中。

StringTable也会因为内存不足导致垃圾回收。

当我们使用new String("hello")去创建一个字符串对象时和直接写String a = "hello"是不一样的。前者是生成了一个String对象,这个对象保存在堆内存中,后者字符串对象仍然保存在堆中,但是它的一个引用保存在字符串常量值中。

String类的intern方法会尝试将一个字符串的引用放入到字符串常量池中,会先检查字符串常量池中是否已经存在,如果已经存在就直接返回字符串常量池中的引用,如果不存在则拷贝一份字符串的引用放入到字符串常量池。

String的不可变性
来看String类的源码,在1.8及以前使用的是一个char数组,到了1.9变成了byte数组。

  1. public final class String
  2. implements java.io.Serializable, Comparable<String>, CharSequence {
  3. /** The value is used for character storage. */
  4. private final char value[];
  5. /** Cache the hash code for the string */
  6. private int hash; // Default to 0
  7. // ...
  8. }

从源码中我们可以看出String类是final的,说明其不可被继承,就不会被子类改变其不可变的特性;其次,String的底层其实是一个被final修饰的数组,说明这个value在确定值后就不能指向一个新的数组。这里我们要明确一点,被final修饰的数组虽然不能指向一个新的数组,但数组中的元素还是可以修改的,既然可以被修改,那String怎么是不可变的呢?因为String类并没有提供任何一个方法去修改数组的值。

五、什么时候会发生GC

5.1 MinorGC(YoungGC)

当需要创建一个新的对象,但是Eden区没有足够空间进行分配的时候就会触发MinorGC,这时会将存活的对象从eden区和Survivor的from区复制到Survivor的to区,然后清空eden区和Survivor的from区。下次Minor GC的时候Survivor的from区和to区就会转变角色。

MinorGC效率越来越慢:(待补充详细)
是否对象创建频率太高(比如在循环体内创建对象,优化代码)
是否有大量弱引用(比如ThreadLocal内存泄漏)弱引用会在yong gc时处理
是否动态代理出了问题

5.2 Full GC

1、元空间内存不够:设置了MaxMetaspaceSize并且频繁加载类(比如加载驱动类),可能导致元空间内存不够,会触发Full GC, Full GC之后还是不够就会出现OOM异常。
2、一次load太大的对象到内存:比如大数组,当对象太大,eden区放不下,就会直接放入老年代,如果老年代也放不下,就会发生Full GC。举个简单例子:比如select语句时没有设置fechsize的大小,返回的ResultSet可能非常大。
3、年轻代达到年龄阈值的对象会放入老年代,如果老年代放不下,就会发生Full GC
4、年轻代发生Yong GC的时候,会把存活的对象从Eden区和survivor的From区放到Survivor的To区,如果survivor的To区放不下,就会被放入到老年代,如果老年代也放不下,就会发生Full GC
5、 JVM有一种优化,会检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果小于,就会发生Full GC
6、用户显示调用System.gc()方法,当然这个方法只是向JVM发了一个GC的请求,JVM并不一定会立刻垃圾回收,但是如果JVM觉得可以垃圾回收会触发Full GC

六、垃圾收集算法

6.1 如何判断对象可以被回收

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)

1 引用计数法:

给对象添加一个引用计数器,每当有一个地方引用,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。这个方法实现简单,效率高,但是引用计数无法解决对象之间循环引用的问题。比如A和B两个对象都是一个方法内临时new出来的对象,A的一个属性是B,B的一个属性是A,首先在这个方法中有两个引用分别指向A和B,然后A和B各有一个引用指向对方,当退出这个方法之后,方法里面指向A和B的两个引用已经断开,也就是说A和B在堆中已经不可达,但是A和B各自还有一个指向对方的引用,引用计数器无法减至0, A和B永远不会回收。(见:https://www.cnblogs.com/igoodful/p/8727241.html)

2 可达性分析:

可达性分析的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,就证明这个对象是不可能再被使用的。

3 哪些对象可以作为GC Roots:

这里应该反过来思考,哪些对象是不能被垃圾回收掉的,很显然只要可能会使用到的对象都不能被垃圾回收掉,比如:

所以这些对象就可以作为GC Roots,也就是引用链的根节点,凡是通过这些对象可达的对象,都不能被回收掉。不可达的对象,说明之后也不会用到了,就可以回收掉。

6.2 标记-清除算法

标记-清除是最基础的收集算法,这个算法分为两个阶段,“标记”和“清除”。首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象(或者标记存活的对象,清理未被标记的对象)。标记清除算法主要优点在于简单,且只有标记阶段需要暂停用户线程,但是标记阶段非常快,一般认为几乎不用暂停用户线程。主要缺点是内存碎片问题,标记清除后会产生大量不连续的内存碎片

6.3 复制算法

复制算法把内存分为两块,每次为对象分配内存只分配到其中的一块。当这一块的内存使用完后,就标记存活对象,将存活的对象复制到另一块区,然后再把原先分配内存的那一块空间一次性全清理掉,之后分配内存就到另外一块上分配。复制算法的缺点是需要将内存分为两部分,总是只使用其中一部分,内存使用率降低了。另外复制阶段需要比较长时间的用户线程暂停。

6.4 标记-整理算法

标记整理算法是根据老年代的特点提出的一种算法,老年代不同于新生代,新生代的对象朝生夕死,如果采用复制算法,并不需要复制太多对象。但是在老年代,对象被回收掉的概率比较低,大部分对象会一直存活,这个时候用复制算法就不太合适了。于时提出了标记-整理算法。标记整理算法的标记过程和“标记-清除”算法一样,但是后续步骤不是直接对可回收对象进行回收,而是将所有存活的对象向移动到另外一端,然后直接清理掉边界以外的内存。

6.5 分代收集

垃圾收集器是根据java虚拟机对内存区域的划分来实现的,现在大部分的java虚拟机都按照对象存活周期的不同将内存分为几块,即将java堆分为新生代和老年代,然后根据每个年代的特点选择不同的垃圾收集算法,这被被称为分代收集。
但是新一代的Java虚拟机对内存区域的划分出现了变化,比如JDK9中出现了基于Region(块)的内存布局形式,针对这种布局形式提出了G1这种新一代的垃圾回收器,G1是一个面向局部(Region)收集的垃圾回收器。

七、垃圾收集器

7.1 新生代收集器

Serial: 单线程收集器,采用复制算法,收集时暂停用户线程,适合单核处理器或者核心数较少的资源受限的环境
ParNew:Serial的多线程版本,默认开启的收集线程数和处理器核心数相同。除了Serial收集器,只有ParNew能与CMS收集器配合
Parallel Scavenge:多线程收集器,采用复制算法,其特点是可以达到一个可控制的吞吐量,吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值
。Parallel Scavenge提供两个参数来控制吞吐量,一个是最大垃圾收集停顿时间(-XX:MaxGCPauseMillis),一个是直接设置吞吐量大小(-XX:GCTimeRatio)GCTimeRatio这个参数的值是一个1到99的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5% (即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。
第二个特点是自适应调节策略(用-XX:+UseAdaptiveSizePolicy来开启),开启之后就不需要手动指定新生代大小,eden与servivor的比例等细节参数了,收集器会进行动态调整。

7.2 老年代收集器

SerialOld:Serial收集器的老年代版本,单线程收集器,采用标记-整理算法
ParallelOld:Parallel Scavenge的老年代版本,多线程收集器,采用标记整理算法,和Parallel Scavenge搭配使用
CMS:以停顿用户线程时间最短为目标的收集器。多线程收集器,部分步骤可与用户线程同时进行,采用标记-清除算法,收集过程分为四个步骤:

CMS是HotSpot虚拟机上第一个真正意义的并发收集器(这里并发(concurrent)指垃圾收集线程和用户线程同时工作;而并行(parallel)指多个垃圾收集线程同时工作,但是用户线程依然暂停,这里并发和并行的概念和Java线程中并发的概念不太一样)。但是缺点也很明显,导致CMS已经处于快被淘汰的边缘,缺点如下:

jdk7与8默认的收集器是parallel scavenge和parallelOld收集器

7.3 新一代收集器

G1:G1是JDK9的默认收集器,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。G1仍然保留了新生代和老年代的概念,但是G1不再是以固定大小、固定数量来进行分代区域划分了,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要扮演新生代的Eden、Survivor或者老年代空间,这样新生代和老年代是一系列Region(不需要连续)的动态集合,收集器能够对扮演不同角色的Region采用不同的策略去处理。Region也成为单次回收的最小单元,这样避免了对堆中进行全区域的垃圾收集。。
Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。如果大对象仍然超过了一个Humongous区域的大小,将会被存放在n个连续的Humongous区域中。
1、如何解决跨Region引用对象的问题:记忆集
2、并发标记阶段如何保证收集线程与用户线程互不干扰:原始快照算法
3、如何建立起可靠的停顿预测模型:衰减均值

八、OOM及解决思路

8.1 可能发生oom的位置

a、Java虚拟机栈:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常(目前hotSpot不支持扩展)。
b、本地方法栈:本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。
c、java堆:如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。
d、方法区:方法区无法满足新的内存分配需求时(比如设置了元空间的最大大小,但是加载了很多类,无法申请新的内存时),将抛出 OutOfMemoryError异常。

总结起来基本都是,无法申请到新的内存,然后会触发GC,如果GC之后仍然无法申请到新的内存,就会发生OOM

8.2 发生OOM之后的解决思路

要解决这个内存区域的异常,常规的处理方法是首先通过内存映像分析工具对Dump出来的堆转储快照进行分析命令为:

  1. jmap -dump:format=b,file=/path/heapdump.hprof <pid>

第一步:首先应确认内存中导致OOM的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(MemoryOverflow)

内存泄漏:
内存泄露是指一些对象已经不需要了,但是因为某些原因无法被回收掉。
如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息以及它到GC Roots引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找出产生内存泄漏的代码的具体位置。

内存溢出:
内存溢出指内存中的对象确实都是必须存活的,但是无法再给新的对象分配内存,这个时候就要考虑是不是内存设置太小了,应当去检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

九、导致系统变慢的原因排查

线上可能会出现多种原因导致系统变慢,比如CPU消耗过高,GC太频繁,死锁,某个线程卡死,某个接口调用速度过慢等,在遇到这些问题的时候,我们可以按如下方式着手:

线上的问题出现的形式是多种多样的,也不一定局限于这几种情况,重要的是先保存现场快照,然后进行分析,或者复现之后再分析。
详细:https://blog.csdn.net/yangg51/article/details/93842922

十、GC调优思路

10.1 JVM调优指标

jvm的调优主要是针对垃圾收集器的收集性能优化,要查找和评估器性能瓶颈,首先要知道性能定义,对于jvm调优来说,我们需要知道以下三个定义属性,依作为评估基础:
a、吞吐量:吞吐量是指不考虑垃圾收集引起的停顿时间或内存消耗,垃圾收集器能支撑应用达到的最高性能指标。精确定义指处理器用于运行用户代码的时间与处理器总消耗时间(垃圾回收+运行用户代码)的比值。ParallerScanvage收集器中指定-XX:GCTimeRatio=n,n可以是一个大于0小于100的整数,数值越大代表允许垃圾收集器的时间越短,相当于吞吐量的倒数。譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5% (即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。
b、停顿时间:垃圾收集器做垃圾回收中断应用执行的时间
c、内存占用:垃圾收集器流畅运行所需要的内存数量。
这三个属性是互斥的,其中任何一个属性性能的提高,都是以另外一个或者两个属性性能的损失作代价,如何取舍,要基于应用的业务需求来确定。

JVM的调优实际上就是调节JVM参数使运行在虚拟机上的应用能够使用更少的内存以及延迟获取更大的吞吐量。

10.2 JVM调优思路

在调优过程中,我们应该谨记以下3个原则,以便帮助我们更轻松的完成垃圾收集的调优,从而达到应用程序的性能要求。
a、MinorGC回收原则:每次minor GC都要尽可能多的收集垃圾对象。以减少应用程序发生Full GC的频率。
b、GC内存最大化原则:处理吞吐量和延迟问题时候,垃圾处理器能使用的内存越大,垃圾收集的效果越好,应用程序也会越来越流畅。
c、GC调优3选2原则: 吞吐量、延迟、内存占用,我们只能选择其中两个进行调优,不可三者兼得。

总体思路如下:
1、打印GC日志:
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -Xloggc:./gc.log
2、分析日志得到关键性指标
3、分析GC原因,调优JVM参数

1 系统延迟需求

在调优之前,我们需要知道系统的延迟需求是那些,以及对应的延迟可调优指标是那些:

以上中,平均停滞时间和最大停顿时间,对用户体验最为重要,可以多关注。基于以上的要求,我们需要统计以下数据:

2 优化新生代的大小

比如在gc日志中,我们看到Minor GC的平均持续时间为0.069秒,频率为0.389秒一次。如果我们系统的设置的平均停滞时间为50ms,当前的69ms明显是太长了,就需要调整。
我们知道新生代空间越大,Minor GC的GC时间越长,频率越低。如果想减少其持续时长,就需要减少其空间大小。如果想减小其频率,就需要加大其空间大小。
为了降低改变新生代的大小对其他区域的最小影响。在改变新生代空间大小的时候,尽量保持老年代空间的大小。比如此次减少了新生代空间10%的大小,应该保持老年代大小不变化,第一步调优后的参数如下变化:
java -Xms359m -Xmx359m -Xmn126m -XX:PermSize=5m -XX:MaxPermSize=5m
新生代的大小有140m变为126,堆大小顺应变化,此时老年代是没有变化的。

3 优化老年代的大小

同上一步一样,在优化之前,也需要采集gc日志的数据。此次我们关注的是FullGC的持续时间和频率。
可以调整老年代的大小来调整FullGC的频率。

10.3 GC常用参数

1 堆栈设置

  1. -Xss:每个线程的栈大小
  2. -Xms:初始堆大小,默认物理内存的1/64
  3. -Xmx:最大堆大小,默认物理内存的1/4
  4. -Xmn:新生代大小, 整堆大小的3/8
  5. -XX:NewSize:设置新生代初始大小
  6. -XX:NewRatio:默认2表示新生代占年老代的1/2,占整个堆内存的1/3
  7. -XX:SurvivorRatio:默认8表示一个survivor区占用1/8Eden内存,即1/10的新生代内存。
  8. -XX:MetaspaceSize:设置元空间大小
  9. -XX:MaxMetaspaceSize:设置元空间最大允许大小,默认不受限制,JVM Metaspace会进行动态扩展。

2 垃圾回收统计信息

  1. -XX:+PrintGC
  2. -XX:+PrintGCDetails
  3. -XX:+PrintGCTimeStamps
  4. -Xloggc:filename

3 收集器设置

  1. -XX:+UseSerialGC:设置串行收集器
  2. -XX:+UseParallelGC:设置并行收集器
  3. -XX:+UseParallelOldGC:老年代使用并行回收收集器
  4. -XX:+UseParNewGC:在新生代使用并行收集器
  5. -XX:+UseParalledlOldGC:设置并行老年代收集器
  6. -XX:+UseConcMarkSweepGC:设置CMS并发收集器
  7. -XX:+UseG1GC:设置G1收集器
  8. -XX:ParallelGCThreads:设置用于垃圾回收的线程数

4 Parallel收集器设置

  1. -XX:ParallelGCThreads:设置并行收集器收集时使用的CPU数。并行收集线程数。
  2. -XX:MaxGCPauseMillis:设置并行收集最大暂停时间
  3. -XX:GCTimeRatio:设置垃圾回收时间占程序运行时间的百分比。公式为1/(1+n)

5 CMS收集器设置

  1. -XX:+UseConcMarkSweepGC:设置CMS并发收集器
  2. -XX:+CMSIncrementalMode:设置为增量模式。适用于单CPU情况。
  3. -XX:ParallelGCThreads:设置并发收集器新生代收集方式为并行收集时,使用的CPU数。并行收集线程数。
  4. -XX:CMSFullGCsBeforeCompaction:设定进行多少次CMS垃圾回收后,进行一次内存压缩
  5. -XX:+CMSClassUnloadingEnabled:允许对类元数据进行回收
  6. -XX:UseCMSInitiatingOccupancyOnly:表示只在到达阀值的时候,才进行CMS回收
  7. -XX:+CMSIncrementalMode:设置为增量模式。适用于单CPU情况
  8. -XX:ParallelCMSThreads:设定CMS的线程数量
  9. -XX:CMSInitiatingOccupancyFraction:设置CMS收集器在老年代空间被使用多少后触发
  10. -XX:+UseCMSCompactAtFullCollection:设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片的整理

6 G1收集器设置

  1. -XX:+UseG1GC:使用G1收集器
  2. -XX:ParallelGCThreads:指定GC工作的线程数量
  3. -XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区
  4. -XX:GCTimeRatio:吞吐量大小,0-100的整数(默认9),值为n则系统将花费不超过1/(1+n)的时间用于垃圾收集
  5. -XX:MaxGCPauseMillis:目标暂停时间(默认200ms)
  6. -XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%)
  7. -XX:G1MaxNewSizePercent:新生代内存最大空间
  8. -XX:TargetSurvivorRatio:Survivor填充容量(默认50%)
  9. -XX:MaxTenuringThreshold:最大任期阈值(默认15)
  10. -XX:InitiatingHeapOccupancyPercen:老年代占用空间超过整堆比IHOP阈值(默认45%),超过则执行混合收集
  11. -XX:G1HeapWastePercent:堆废物百分比(默认5%)
  12. -XX:G1MixedGCCountTarget:参数混合周期的最大总次数(默认8)

十一、java程序编译与代码优化

11.1 前端编译器、解释器和即时编译器

可ava程序员比较熟悉的是将java源文件(.java文件)编译为java字节码文件(.class文件),字节码文件是java虚拟机的"机器语言",只要是符合规范的字节码文件,java虚拟机都能识别,这就是java语言平台无关系的关键。但是字节码文件只是一种中间文件,java虚拟机能识别,但是计算机本身是不能识别的,要交给计算机去执行java虚拟机还要将字节码翻译称计算机能识别的语言,将java原文件编译为java字节码文件的过程又被称为前端编译

计算机不能直接理解高级语言,只能直接理解机器语言,所以必须要把高级语言翻译成机器语言,计算机才能执行高级语言编写的程序,有两种方式,一种叫解释执行,另外一种叫编译执行

编译器又分为提前编译器和即时编译器,所谓提前编译器就是将程序提前全部编译为机器代码,然后再交给计算机执行;而即时编译器是指程序已经运行了一段时间,通过收集到的热点信息来针对性的对一部分源码进行编译,之后运行这部分代码的时候将按照编译后的结果运行,这种运行时进行编译的过程叫做即时编译(JIT),即时编译的过程相对之前的前端编译而言又叫做后端编译

在HotSpot虚拟机中是解释器和即时编译器并存的,当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,收集到越来越多的热点信息,即时编译器将逐渐发挥作用,把越来越多的代码编译成机器代码之后,可以获取更高的执行效率。

当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。此外,如果编译后出现“罕见陷阱”,可以通过逆优化退回到解释执行。

11.2 哪些代码适合即时编译

对于哪些只执行一次的代码而言,解释执行其实总是比JIT编译执行要快。对只执行一次的代码做JIT编译再执行,可以说是得不偿失。对只执行少量次数的代码,JIT编译带来的执行速度的提升也未必能抵消掉最初编译带来的开销。只有对频繁执行的代码,JIT编译才能保证有正面的收益。

另外编译后代码的大小相对于字节码的大小,膨胀比达到10倍以上是很正常的。如果所有代码都进行编译就会显著增加代码所占空间,导致“空间爆炸”,所以只有对执行频繁的代码才值得编译。

那么哪些代码才适合做即时编译呢?实际上运行过程中会被即时编译器编译的“热点代码”有两类:

对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。

11.3 如何判断一段代码是不是热点代码

要知道某个方法或一段代码是不是热点代码,需要进行热点探测。目前主流的热点探测有两种方式:

在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两个计数器:方法调用计数器回边计数器。在确定的虚拟机运行参数下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。

11.4 hotspot中的即时编译器

HotSpot虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别被称为“客户端编译器
”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为C1编译器和C2编译器,第三个是在
JDK10时才出现的用来代替C2的Graal编译器。Graal编译器目前还处于实验状态。

对于客户端编译器(c1)来说,它是一个相对简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。

而服务端编译器(c2)则是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器,它会执行大部分经典的优化动作,如:无用代码消除、循环展开、循环表达式外提、消除公共子表达式)、常量传播、基本块重排序等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除、空值检查消除等。

在分层编译的工作模式出现以前,HotSpot虚拟机通常是采用解释器与其中一个编译器直接搭配的方式工作,程序使用哪个编译器,只取决于虚拟机运行的模式,HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在客户端模式还是服务端模式。

但是到了jdk7之后,默认就会使用分层编译策略

以上层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可以调整分层的数量。实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码都可能会被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间。

JVM Server模式与client模式的差别?
HotSpot虚拟机会自动选择java程序启动的模式。实际上使用java -version命令就可以查看当前JVM处于什么模式

  1. > java -version
  2. java version "1.8.0_161"
  3. Java(TM) SE Runtime Environment (build 1.8.0_161-b12)
  4. Java HotSpot(TM) 64-Bit Server VM (build 25.161-b12, mixed mode)

可以看到jvm处于混合模式,也就是采用分层编译,c1和c2混用。

server模式和client模式的主要差别在于即时编译器的选择:
client模式使用的时c1编译器,它是一个简单快速的编译器,主要关注点在于局部优化,而放弃许多耗时较长的全局优化手段。而server模式使用对的是c2编译器,是一个充分优化过的高级编译器。使用c1编译器程序启动速度更快,但是程序运行一段时间之后,使用c2编译器的程序运行性能更好。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注