双亲委派机制说一下
工作流程
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,他首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子类加载器才会尝试自己去完成加载
优势
使用双亲委派机制来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能保证是同一个类。反之如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己编写了一个名为java.lang.Object的类,并放到了ClassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证。
优势概括
- 避免类的重复加载
- 保护程序安全,防止核心API被随意篡改
谈谈沙箱安全机制
Java安全模型的核心就是Java沙箱(sandbox),什么是沙箱?沙箱是一个限制程序运行的环境。沙箱机制就是将Java代码限定在虚拟机JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。沙箱主要限制系统资源访问,那系统资源包括什么? CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。
组成沙箱的基本组件:
-
字节码校验器(bytecode verifier)︰确保java类文件遵循java语言规范。这样可以帮助Java程序实现内存保护。但并不是所有的类文件都会经过字节码校验,比如核心类。
-
类装载器(class loader) :其中类装载器在3个方面对Java沙箱起作用
- 它防止恶意代码去干涉善意的代码;//双亲委派机制
- 它守护了被信任的类库边界;
- 它将代码归入保护域,确定了代码可以进行哪些操作。
虚拟机为不同的类加载器载入的类提供不同的命名空间,命名空间由一系列唯一的名称组成,每一个被装载的类将有一个名字,这个命名空间是由java虚拟机为每一个类装载器维护的,它们互相之间甚至不可见。
类装载器采用的机制是双亲委派模式。
答案参考博客如下:
- https://www.cnblogs.com/wshjyyysys/p/15872661.html
详细说说类加载器
从Java虚拟机的角度来看,类加载器分为两种:
- 一种是启动类加载器,这个类加载器是使用C++语言实现的,是虚拟机自身的一部分;
- 另一种就是其它所有类加载器,这些类加载器都由Java语言实现,独立存在与虚拟机外部,并且全部都继承子抽象类
java.lang.ClassLoader
从Java开发人员的角度来看,类加载器分为三层:启动类加载器、扩展类加载器、应用程序类加载器
启动类加载器(Bootstrap Class Loader):
启动类加载器负责加载存放在<JAVA_HOME>\lib(这里的java_home指的是jre目录)
目录,或者被-Xbootclasspath
参数所指定的路径中存放的,而且是java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录也不会被加载)类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器的时候,如果需要把加载请求委派给启动类加载器取处理,那直接使用null代替即可
扩展类加载器(Extension Class Loader):
这个类加载器是在类sum.misc.Launcher$ExtClassLoader
中以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext
目录中,或者被java.ext.dirs
系统变量所指定的路径中所有的类库
应用程序类加载器(Application Class Loader):
这个类加载器由sum.misc.Launcher$AppClassLoader
来实现。由于应用程序类加载器是ClassLoader
中的getSystemClassLoader()
方法的返回值,所以有些场合也称它为“系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中的默认类加载器。
JDBC、Tomcat破坏双亲委派模型谈谈你的理解?
JDBC也破坏了双亲委派模型?
我认为JDBC是不是破坏了双亲委派机制,其实见仁见智了。
JDBC定义了Driver接口,具体的实现由各个厂商进行实现(比如MySQL)
类加载器有个规则就是:如果一个类由类加载器A加载,那么这个类的依赖的类也是由当前这个类加载器去加载。
在JDBC 4.0之后,我们是直接使用DriverManager来获取Connection,DriverManager在java.sql
包下,显然是由BootStrap类加载器进行装载,当我们调用DriverManager.getConnection()
时,会用到驱动类,所以一定会加载数据库厂商自己实现的Driver类。
但BootStrap ClassLoader会能加载到各个厂商实现的类吗?显然不可以,这些实现类又没在java包中,怎么可能加载得到呢?
DriverManager的解决方案就是,在DriverManager初始化的时候,得到「线程上下文加载器」。去加载Driver驱动的实现类的时候,是使用「线程上下文加载器」去加载的,而这里的线程上下文加载器实际上还是App ClassLoader。
那这种情况,有的人觉得破坏了双亲委派机制,因为本来明明应该是由BootStrap ClassLoader进行加载的,结果你来了一手「线程上下文加载器」,改掉了「类加载器」
有的人觉得没破坏双亲委派机制,只是改成由「线程上下文加载器」进行类加载,但还是遵守着:「依次往上找父类加载器进行加载,都找不到时才由自身加载」。认为”原则”上是没变的。
答案参考及推荐博客
- https://blog.csdn.net/sinat_34976604/article/details/86723663
- https://blog.csdn.net/u012129558/article/details/81540804
- https://www.zhihu.com/question/466696410
Tomcat为什么要破坏双亲委派模型
简单记:为了让web应用之间的类库相互隔离、同时保证tomcat自身类库的安全、为了热部署
每个Tomcat的webappClassLoader
加载自己的目录下的class文件,不会传递给父类加载器。
答案参考博客如下:
- https://www.cnblogs.com/lyc88/articles/11431383.html
- https://blog.csdn.net/liweisnake/article/details/8470285
- https://www.zhihu.com/question/466696410
谈谈类加载的过程
类加载的过程分为五个阶段进行,分别是加载、验证、准备、解析和初始化这几个动作。
1、加载
在加载阶段,Java虚拟机会完成三件事情
- 通过一个类的全限定类名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口
衍生问题:
- 数组和类的加载是有区别的
- 类加载阶段的顺序问题
2、连接
连接阶段分为三步,分别是验证准备解析
连接之验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段大致上会完成四个阶段的检查动作:
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
文件格式验证:
该阶段要验证字节流是否符合Class文件格式的规范,并能被当前版本的虚拟机处理。这一阶段可能包括如下验证点:
- 是否以魔数开头
- 主次版本号是否在当前Java虚拟机接受的范围之内
- 常量池的常量中是否有不被支持的常量类型
- 。。。
该阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息地要求。这个阶段地验证是基于二进制字节流进行的,只有通过了这个阶段地验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了
元数据验证:
该阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求,这个阶段可能包括的验证点如下:
- 这个类是否有父类(除了java.lang.Object之外,所有类都应当有父类)
- 这个类的父类是否继承了不允许被继承的类(被final修饰的类)
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
- 。。。
该阶段的主要目的是对类的元数据信息进行语义校验,保证不存在与《Java语言规范》定义相悖的元数据信息
字节码验证:
该阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型校验完毕以后,这阶段就要对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为,例如:
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作数栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况。
- 保证任何跳转指令都不会跳转到方法体以外的字节指令上
- 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的
- 。。。
符号引用验证:
最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。本阶段需要校验下列内容:
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在指定类中是否存在符合方法的字段描述以及简单名称所描述的方法和字段。
- 符号引用中的类、字段、方法的可访问性(private、protected、public、<package>)是否可被当前类访问。
- 。。。
符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java虚拟机将会抛出一个java.lang.IncompatibleClassChangeError
的子类异常,
典型的如:
java.lang.IllegalAccessError
java.lang.NoSuchFieldError
java.lang.NoSuchMethodError
准备
准备阶段是正式为类变量(即静态变量,被static修饰的变量)分配内存并设置类变量的初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑的区域,在JDK7之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的:而在JDK7及以后,类变量会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。
对类变量设置初始值“通常情况”下是数据类型的零值。除非类变量被final修饰了
例如:
public static final int value = 123;
类的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指的初始值。在编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置讲value赋值为123
解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程
衍生问题:
什么样的方法的符号引用会在解析阶段转换为直接引用?
invokestatic和invokespecial指令调用的方法都可以在解析阶段中确定唯一的调用版本,被final修饰的方法也可以在解析阶段把符号引用解析为直接引用
3、初始化
进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会执行类构造器clinit
注意:
clinit
是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的
什么时候会触发类的初始化
关于在什么情况下需要开始类加载过程的第一个阶段“加载”,《Java虚拟机规范》中并没有进行强制约束,这带你可以交给虚拟机的具体实现来自由把我。但是对于初始化阶段,《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备阶段自然需要在此之前开始):
- 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要触发其初始化阶段,能生成这四条指令的典型场景有:
- 使用new关键字实例化对象的时候
- 读取或设置一个类型的静态字段的时候(被final修饰、已在编译器把结果放入常量池的静态字段除外)
- 调用一个类型的静态方法的时候
- 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化
- 当初始化类的时候,如果发现其父类还没有被进行过初始化,则需要先触发其父类的初始化
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
- 当使用JDK7新加入的动态语言支持时,如果一个
java.lang.invoke.MethodHandle
实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化 - 当一个接口中定义了JDK8新加入的默认方法时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化
说一下JVM内存模型
JVM内存模型包括:程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区
程序计数器
程序计数器是一块比较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
它是线程私有的,并且此内存区域是唯一一个在《Java虚拟机规范》中没有明确规定任何OutOfMemoryError情况的区域。
为什么要设计为线程私有?
由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的程序计数器互不影响,独立存储。
程序计数器中记录的内容是什么?
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则为空(Undefined)。
Java虚拟机栈
与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法返回地址等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常
局部变量表:
局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。
局部变量表以变量槽为最小单位,《Java虚拟机规范》中并没有明确指出一个变量槽应占用的内存空间大小,只是说每个变量操都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据。《Java虚拟机规范》允许变量槽的长度可以随着处理器、操作系统或者虚拟机实现的不同而发生变化,但是它会保证即使在64位虚拟机中使用了64位物理空间去实现一个变量槽,虚拟机仍要使用对齐和补白的手段让变量槽在外观上看起来与32位虚拟机中的一致。
对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。
Java虚拟机使用索引定位的方式使用局部变量表,索引值是从0开始的。需要注意的是如果访问的是64位数据类型的变量由于它是使用两个变量槽实现的,Java虚拟机不允许采用任何方式单独访问其中的某一个。
当一个方法被调用的时候,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字this来访问这个隐含的参数。其余参数按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完后,再根据方法体内部定义的变量顺序和作用域分配其余的槽。
为了尽可能节省栈帧耗用的内存空间,局部变量表的槽是可以重用的,方法体中定义的变量,其作用域不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的槽就可以交给其他变量来重用。
局部变量如果定义了但是没有被赋初始值,那它是完全不能使用的。
操作数栈
操作数栈也被称为操作栈或者表达式栈,它具有栈先入后出的性质。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。操作数栈中的元素可以是任意Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。Javac编译器的数据流分析工作保证了在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
当一个方法开始执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。举个例子:整数加法的字节码指令iadd,这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会把这两个int值出栈并相加,然后将相加的结果重新入栈
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。再以上面的iadd指令为例,这个指令只能用于整数的加法,它在执行时,最接近栈顶的两个元素的数据类型必须为int型,不能出现一个long和一个float使用iadd命令相加的情况。
在概念模型当中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在方法调用时就可以直接公用一部分数据,无序进行额外的参数赋值传递。
Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另一部分将在每一次运行期间都转化为直接引用,这部分就成为动态连接。
方法返回地址
当一个方法开始执行后,只有这两种方式退出这个方法。
第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为“正常调用完成“
另一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为“异常调用完成”。一个方法使用异常完成出后的方式退出,是不会给它的上层调用者提供任何返回值的。
无论采用何种退出方式,在方法退出之后,都必须返回到最初方法调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用于帮助恢复它的上层主调方法的执行状态。一般来说,方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值而方法异常退出时,返回地址是奥通过异常处理表来确定,栈帧中就一般不会保存这部分信息。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
本地方法栈
本地方法栈与虚拟机栈所发挥的作用非常相似,其区别是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
《Java虚拟机规范》对本地方法栈中使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(比如HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一了。与虚拟机栈一样,本地方法栈也会在栈深度移除或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常
Java堆
对于Java应用程序来说,Java堆(JavaHeap)是虚拟机所管理的内存中最大的一块。
Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存,这里的“几乎”指的是从实现角度来看的,随着Java语言的发展,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大、栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆内存上就变得不那么绝对了。
Java堆是垃圾收集器管理的内存区域。从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集的理论设计的,所以Java堆中经常会被划分为“新生代” “老年代” “永久代” “Eden空间” “From Survivor空间” “To Survivor空间”,需要注意的是这些空间并非某个Java虚拟机具体实现的固有内存布局,更不是《Java虚拟机规范》里对Java堆的进一步细致划分。
补充:Java堆在JDK7中逻辑上分为新生代+老年代+永久代,在JDK8中逻辑上分为了新生代+养老代+元空间
以G1收集器的出现为分界,在这之前它内部的垃圾收集器全部基于前面说的这种分代,但是到了今天HotSpot里面也出现了不采用分代设计的垃圾收集器
如果从内存分配的角度看,所有线程共享Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变Java堆中存储内容的共性,无论是那个区域,存储的都只能是对象的实例,将Java堆细分的目的只是为了更好的回收内存,或者更快的分配内存。
根据《Java虚拟机规范》的规范,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。但是对于大对象(典型的如数组对象),多数虚拟机处于实现简单、存储高效的考虑,很可能会要求连续的内存空间。
Java堆既可以被实现成固定大小,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的。如果再Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。
衍生问题:
- TLAB说说
- 逃逸分析是什么?
- 栈上分配、标量替换
谈谈TLAB?
因为堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据。由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。从而采用TLAB来保证分配对象的线程安全,以及提高对象分配时的效率。
尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
逃逸分析是什么?
通过逃逸分析,Java HotSpot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
栈上分配
JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
标量替换
标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型(基本数据类型)就是标量。相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
以上代码,经过标量替换后,就会变成
方法区
方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做”非堆“,目的就是与Java堆区分开。
需要注意的是再JDK8以前方法区的实现叫做永久代,现在回过头来看,当时将方法区设计为永久代并不是一个好的注意,这种设计导致了Java应用更容易于导内存溢出的问题。所以再JDK8之后就废弃了永久代的概念,改用在本地内存中实现的元空间来代替了
《Java虚拟机规范》对方区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定的大小或者可扩展外,甚至还可以选择不实现垃圾回收。相对而言,垃圾收集行为再这个区域的确是比较少出现的,但并非数据进入方法区之后就永久存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的写在,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。(以前sum公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致的内存泄露)
根据《Java虚拟机规范》的规定,如果方法去无法满足新的内存分配需求时,将会抛出OutOfMemoryError异常
衍生问题:
- 方法区演进细节?
- 为什么将永久代换成元空间?
- StringTable为什么调整位置?
方法区的演进细节
Hotspot中方法区的变化:
JDK版本 | 变化 |
---|---|
JDK1.6及以前 | 有永久代(permanent generation),静态变量存储在永久代上 |
JDK1.7 | 有永久代,但已经逐步 “去永久代”,字符串常量池,静态变量移除,保存在堆中 |
JDK1.8 | 无永久代,类型信息,字段,方法,常量保存在本地内存的元空间,但字符串常量池、静态变量仍然在堆中。 |
JDK6
方法区由永久代实现,使用 JVM 虚拟机内存(虚拟的内存)
JDK7
方法区由永久代实现,使用 JVM 虚拟机内存
JDK8
方法区由元空间实现,使用物理机本地内存
永久代为什么要被元空间替代?
-
为永久代设置空间大小是很难确定的。在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。 比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误 。
Exception in thread ‘dubbo client x.x connector’ java.lang.OutOfMemoryError:PermGen space
而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。 因此,默认情况下,元空间的大小仅受本地内存限制。
-
对永久代进行调优是很困难的。方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再用的类型,方法区的调优主要是为了降低
Full GC
- 有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,k提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)。
- 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。
StringTable 为什么要调整位置?
JDK7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在Full GC的时候才会执行永久代的垃圾回收,而Full GC是老年代的空间不足、永久代不足时才会触发。这就导致StringTable回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
什么是解释器?什么是JIT编译器?
- 解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
- JIT(Just In Time Compiler)编译器:就是虚拟机将源代码一次性直接编译成和本地机器平台相关的机器语言,但并不是马上执行。
追问:为什么Java是半编译半解释型语言?
- JDK1.0时代,将Java语言定位为“解释执行”还是比较准确的。再后来,Java也发展出可以直接生成本地代码的编译器。
- 现在JVM在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行。JIT编译器将字节码翻译成本地代码后,就可以做一个缓存操作,存储在方法区的JIT 代码缓存中(执行效率更高了),并且在翻译成本地代码的过程中可以做优化。
追问:既然有了JIT即时编译器为什么还需要解释器呢?
-
有些开发人员会感觉到诧异,既然HotSpot VM中已经内置JIT编译器了,那么为什么还需要再使用解释器来“拖累”程序的执行性能呢?
比如JRockit VM内部就不包含解释器,字节码全部都依靠即时编译器编译后执行。
JRockit虚拟机是砍掉了解释器,也就是只采及时编译器。那是因为呢JRockit只部署在服务器上,一般已经有时间让他进行指令编译的过程了,对于响应来说要求不高,等及时编译器的编译完成后,就会提供更好的性能
首先明确两点:
- 当程序启动后,解释器可以马上发挥作用,响应速度快,省去编译的时间,立即执行。
- 编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间,但编译为本地代码后,执行效率高。
所以:
- 尽管JRockit VM中程序的执行性能会非常高效,但程序在启动时必然需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点
- 在此模式下,在Java虚拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。
- 同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”(后备方案)。
谈谈JVM中对象创建的步骤
当Java虚拟机遇到一条new指令,首先去检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化。如果没有那么先执行相应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需空间的大小在类加载完成后便可以完全确定。
因为对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指着你来分配内存的情况。解决这个问题有两种可选的方案:
- 对分配内存空间的动作进行同步处理,采用CAS+失败重试的方式保证更新操作的原子性
- 把内存分配动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存,就是TLAB,哪个线程要分配内存,就在哪个线程的本地缓冲区中进行分配,只有本地缓冲区用完了,分配新的缓冲区时才需要同步锁定
内存分配完成之后虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,是程序能访问到这些字段的数据类型所对应的零值。
接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象头之中。根据寻笔记当前运行状态的不同,如是否使用偏向锁等,对象头会有不同的设置方式。
在上面的工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角来看,对象创建才刚刚开始——构造函数,即Class文件中的<init>()
方法还没有执行,所有的字段都为默认的灵芝,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说,new指令之后会接着执行<init>()
方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算被完全构造出来。
图解整个内存布局
追问:谈谈你了解的给对象分配内存的方式有哪些?
-
如果内存规整:采用指针碰撞分配内存
如果内存是规整的,那么虚拟机将采用的是指针碰撞法(Bump The Point)来为对象分配内存。意思是所有用过的内存在一边,空闲的内存放另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针往空闲内存那边挪动一段与对象大小相等的距离罢了。
如果垃圾收集器选择的是Serial ,ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带Compact(整理)过程的收集器时,使用指针碰撞。
标记压缩(整理)算法会整理内存碎片,堆内存一存对象,另一边为空闲区域
-
如果内存不规整
如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表来为对象分配内存。意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式成为了 “空闲列表(Free List)”
选择哪种分配方式由Java堆是否规整所决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定
追问:JVM是如何通过栈帧中的对象引用访问到其内部的对象实例呢?
对象的两种访问方式:句柄访问和直接指针
-
句柄访问
缺点:在堆空间中开辟了一块空间作为句柄池,句柄池本身也会占用空间;通过两次指针访问才能访问到堆中的对象,效率低
优点:reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference本身不需要被修改
-
直接指针(HotSpot采用)
优点:直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据
缺点:对象被移动(垃圾收集时移动对象很普遍)时需要修改 reference 的值
为什么 JDK9 改变了 String 的结构
为什么改为 byte[] 存储?
-
jdk1.8中string类的当前实现将字符存储在char数组中,每个字符使用两个字节(16位)。
从许多不同的应用程序收集的数据表明,字符串是堆使用的主要组成部分,而且大多数字符串对象只包含拉丁字符(Latin-1)。这些字符只需要一个字节的存储空间,因此这些字符串对象的内部char数组中有一半的空间将不会使用,产生了大量浪费。
-
jdk1.8中的 String 类使用 UTF-16 的 char[] 数组存储,在jdk1.9改为 byte[] 数组 外加一个编码标识存储。该编码表示如果你的字符是ISO-8859-1或者Latin-1,那么只需要一个字节存。如果你是其它字符集,比如UTF-8,你仍然用两个字节存
结论:String再也不用char[] 来存储了,改成了byte [] 加上编码标记,节约了一些空间
StringTable 为什么要调整?
为什么要调整位置?
- 永久代的默认空间大小比较小
- 永久代垃圾回收频率低,大量的字符串无法及时回收,容易进行Full GC产生STW或者容易产生OOM:PermGen Space
- 堆中空间足够大,字符串可被及时回收
在JDK 7中,interned字符串不再在Java堆的永久代中分配,而是在Java堆的主要部分(称为年轻代和年老代)中分配,与应用程序创建的其他对象一起分配。
此更改将导致驻留在主Java堆中的数据更多,驻留在永久代中的数据更少,因此可能需要调整堆大小。
JVM垃圾回收相关算法
标记阶段
引用计数算法
过程描述
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数为0的对象就是不可能在被使用的。
优势
原理简单,效率也很高,像Python就是使用的引用计数算法来进行标记的
缺点
主流的Java虚拟机里没有选用引用计数算法来管理内存,主要原因是因为这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确的工作,比如单纯的引用计数算法很难解决对象之间的相互引用关系。
追问:Python如何解决的相互依赖的问题?
- 手动解除:很好理解,就是在合适的时机,解除循环引用的关系。
- 使用弱引用weakref,weakref是Python提供的标准库,旨在解决循环引用。
可达性分析算法
当前主流的商用程序语言的内存管理子系统,都是通过可达性分析算法来判定对象是否存活的。
基本流程
通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,如果某个对象到GC Roots间没有任何引用链相连(从GC Roots到这个对象不可达),则证明此对象是不可能在被使用的,应当被回收。
优势
能够解决循环引用的问题
追问:可以作为GC Roots的对象有哪些?见下面
清除阶段
标记-清除算法(Mark-Sweep)
算法流程
首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象(也可以反过来),标记存活的对象,统一回收所有未被标记的对象。
缺点
- 执行效率不稳定,如果Java堆中包含大量对象,并且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量的增长而降低
- 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
标记-复制算法
算法流程
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉
缺点:
- 此算法的缺点也是很明显的,就是需要两倍的内存空间
- 如果内存中多数对象都是存活的情况,这种算法将会产生大量的内存间复制的开销。
优点:
- 没有标记和清除过程,实现简单,运行高效
- 复制过去以后保证空间的连续性,不会出现“碎片”问题。
标记-整理算法
算法流程
首先标记出所有需要回收的对象,在标记完成后,让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存
缺点:
- 从效率上来说,标记-整理算法要低于复制算法
- 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址(因为HotSpot虚拟机采用的不是句柄池的方式,而是直接指针)
- 移动过程中,需要全程暂停用户应用程序。即:STW
优点:
- 消除了标记-清除算法当中,内存区域分散(内存碎片)的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的
- 消除了复制算法当中,内存减半的高额代价
对比三种清除算法
标记清除 | 标记整理 | 复制 | |
---|---|---|---|
速率 | 中等 | 最慢 | 最快 |
空间开销 | 少(但会堆积碎片) | 少(不堆积碎片) | 通常需要活对象的2倍空间(不堆积碎片) |
移动对象 | 否 | 是 | 是 |
效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。
而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段。
JVM垃圾回收的时候如何确定垃圾
有两种方式可以判断一个对象是否可以被回收
第一种是使用引用计数法
第二种是使用可达性分析
而我们HotSpot虚拟机采用的则是可达性分析来确定垃圾的
追问:为什么不使用引用计数算法?
使用引用计数算法判断对象存活的过程是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为零的对象就是不可能在被使用的对象了,也就是垃圾对象,可以被回收了。
虽然引用计数算法原理简单,判定效率高,但是在主流的Java虚拟机里面都没有选用该算法来管理内存,其中主要的原因就是这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,比如对象之间地循环引用地问题
像python就是使用地引用计数算法,它在解决循环引用地问题时就需要在合适地时机手动解除循环引用,或者使用weakref库来解决
追问:可达性分析算法确定垃圾的过程是什么?
可达性分析算法判断对象是否存过的基本思路时通过一系列为“GC Roots”的根对象作为起始节点集,从这些节点开始根据引用关系向下搜索,如果某个对象到GC Roots减没有任何引用链项链就说明该对象不可达,也就说明这个对象不可能再次被使用了,也就是个垃圾对象了
追问:哪些对象可以作为GC Root
在Java技术体系中,固定可作为GC Roots的对象包括以下几种:
-
在虚拟机栈(栈中中的本地变量表中引用的对象)中引用的对象
比如当前正在运行的方法所使用到的参数、局部变量、临时变量等
-
在方法区中静态属性引用的对象
比如Java类的引用类型静态变量
-
在本地方法栈中JNI(就是通常所说的Native本地方法)引用的对象
-
Java虚拟机内部的引用
如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepition、OutOfMemoryError)等,还比如系统类加载器
-
所有被同步锁(synchronized关键字)持有的对象
-
反应Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
除了这些固定的GC Roots集合以外,根据所选用的垃圾收集器以及当前回收的内存区域的不同,还可以有一些对象“临时性”地加入,共同构成完整地GC Roots集合。
比如分代收集和局部回收(Partial GC),如果只针对Java堆中某一块区域发器垃圾收集时(如最典型地只针对新生代地垃圾收集),必须考虑到内存区域时虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中,才能保证可达性分析的正确性
谈谈finalization机制
对象销毁前的回调函数:finalize()
生存还是死亡?
由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态。
如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。
一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它立即进行回收就是不合理的。为此,定义虚拟机中的对象可能的三种状态。如下:
- 可触及的:从根节点开始,可以到达这个对象。
- 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活。
- 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次。
对象死亡的具体过程
判定一个对象objA是否可回收,至少要经历两次标记过程:
- 如果对象objA到GC Roots没有引用链,则进行第一次标记。
- 进行筛选,判断此对象是否有必要执行finalize()方法
- 如果对象objA没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执 行”,objA被判定为不可触及的。
- 如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到F-Queue队列(引用队列)中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行。
- finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合(移出去就表示第二次标记没有标记上)。之后,对象即使再次出现没有引用存在的情况。在这个情况下,finalize()方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize()方法只会被调用一次。
为什么通常会将-Xms和-Xmx配置为相同的值
- -Xms用于表示堆区的初始内存大小
- -Xmx则用于表示堆区的最大内存大小
假设两个不一样,初始内存小,最大内存大。在运行期间如果堆内存不够用了,会一直扩容直到最大内存。如果内存够用且多了,也会不断的缩容释放。频繁的扩容和释放造成不必要的压力,避免在GC之后调整堆内存给服务器带来压力。如果两个设置一样的就少了频繁扩容和缩容的步骤。内存不够了就直接报OOM。
所以通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分割计算堆区的大小,从而提高性能
JVM的参数类型有哪几种
-
标配参数(从JDK1.0 - Java12都在,很稳定)
- -version
- -help
- java -showversion
-
X参数(了解)
- -Xint:解释执行
- -Xcomp:第一次使用就编译成本地代码
- -Xmixed:混合模式
-
XX参数(重点)
- Boolean类型
- 公式:-XX:+ 或者 - 某个属性 + 表示开启,-表示关闭
- Case:-XX:-PrintGCDetails:表示关闭了GC详情输出
- key-value类型
- 公式:-XX:属性key=属性value
- 不满意初始值,可以通过下列命令调整
- case:
- 设置元空间大小:-XX:MetaspaceSize=1024m
- 设置对象存活阈值:-XX:MaxTenuringThreshold=15
注意-Xms等价于-XX:InitialHeapSize(注意-XX:InitialHeapSize优先级更大,如果同时设置则采用该配置的值)、-Xmx等价于-XX:MaxHeapSize
- Boolean类型
追问:如何查看一个正在运行中的java程序,它的某个jvm参数是否开启?具体值是多少?
- 先通过jps获取正在运行的java进程号
- 使用
jinfo -flag 参数名称 进程号
jinfo -flags 进程号
:查看当前进程的所有初始化配置和自己的命令行配置
追问:如何查看JVM默认参数
java -XX:+PrintFlagsInitial -version
或者java -XX:+PrintFlagsInitial(重要参数)
:查看初始默认值java -XX:+PrintFlagsFinal -version
:表示修改以后最终的值。会将JVM的各个结果都进行打印。如果有:=
表示修改过的,=
表示没有修改过的
说说常用的JVM基本配置参数
- -
Xms
和Xmx
查看JVM的初始化堆内存 -Xms 和最大堆内存 Xmx -XX:+PrintCommandLineFlags
:查看常用参数的当前配置和当前使用的垃圾回收器-Xss
:设计单个线程栈的大小,一般默认为512K~1024K,等价于 -XX:ThreadStackSize-Xmn
:设置年轻代大小(非特殊情况建议默认)-XX:MetaspaceSize
:设置元空间大小(建议设置,因为初始值低)-XX:PrintGCDetails
:输出详细GC收集日志信息-XX:SurvivorRatio
(建议默认配置):调节新生代中 eden 和 S0、S1的空间比例,默认为 -XX:SuriviorRatio=8,Eden:S0:S1 = 8:1:1(经过实际测试是6:1:1)-XX:NewRatio
(建议默认配置):配置年轻代new 和老年代old 在堆结构的占比。默认: -XX:NewRatio=2 新生代占1,老年代2,年轻代占整个堆的1/3-XX:MaxTenuringThreshold
:设置垃圾最大年龄,SurvivorTo和SurvivorFrom互换,原SurvivorTo成为下一次GC时的SurvivorFrom区,部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认为15),最终如果还是存活,就存入老年代。这里就是调整这个次数的,默认是15,并且设置的值 在 0~15之间
衍生问题:
堆内存默认的初始大小(物理内存的1/64)和默认最大大小(物理内存的1/4)
-Xss占用空间的细节问题:具体占用随着系统的不同大小不同,并且如果使用
jinfo -flag ThreadStackSize
查看为0,表示用的是系统出厂默认配置元空间出厂大小是多少?对于一个64位服器端 JVM 来说,其默认的元空间大小为21MB
为什么建议提前将元空间调大?
其默认的元空间的默认值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。
如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。
-XX:MaxTenuringThreshold
可以设置为20吗? 不可以,只能设置[0,15]的值,不同jdk版本有所不同(经过测试jdk11可以设置为16)
强引用、软引用、弱引用、虚引用分别是什么?
我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象
在JDK1.2版之后,Java对引用的概念进行了扩充,将引用分为:
- 强引用(Strong Reference)
- 软引用(Soft Reference)
- 弱引用(Weak Reference)
- 虚引用(Phantom Reference)
除强引用外,其他3种引用均可以在java.lang.ref包中找到它们的身影。如下图,显示了这3种引用类型对应的类,开发人员可以在应用程序中直接使用它们。
Reference子类中只有终结器引用是包内可见的,其他3种引用类型均为public,可以在应用程序中直接使用
- 强引用:最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“
object obj=new Object()
”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。宁可报OOM,也不会GC强引用 - 软引用:在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
- 弱引用:被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象
- 虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知
软引用和弱引用的使用场景
场景:假如有一个应用需要读取大量的本地图片
- 如果每次读取图片都从硬盘读取则会严重影响性能
- 如果一次性全部加载到内存中,又可能造成内存溢出
此时使用软引用可以解决这个问题
设计思路:使用HashMap来保存图片的路径和相应图片对象关联的软引用之间的映射关系,在内存不足时,JVM会自动回收这些缓存图片对象所占的空间,从而有效地避免了OOM的问题
Map<String, SoftReference<String>> imageCache = new HashMap<String, SoftReference<Bitmap>>();
追问:WeakHashMap是什么?
比如一些常常和底层打交道的,mybatis等,底层都应用到了WeakHashMap
WeakHashMap和HashMap类似,只不过它的Key是使用了弱引用的,也就是说,当执行GC的时候,HashMap中的key会进行回收。
请谈谈你对OMM的认识
JVM中常见的两个错误
- StackoverFlowError :栈溢出,我们有最简单的一个递归调用,就会造成堆栈溢出,也就是深度的方法调用
- OutofMemoryError: java heap space:创建了很多对象,导致堆空间不够存储
除此之外,还有以下的错误
-
java.lang.StackOverflowError
-
java.lang.OutOfMemoryError:java heap space
-
java.lang.OutOfMemoryError:GC overhead limit exceeeded
GC回收时超过了98%的时间用来做GC,并且回收了不到2%的堆内存会抛出OutOfMemoryError
连续多次GC都只回收了不到2%的极端情况下,才会抛出。假设不抛出GC overhead limit 错误会造成什么情况呢?
那就是GC清理的这点内存很快会再次被填满,迫使GC再次执行,这样就形成了恶性循环,CPU的使用率一直都是100%,而GC却没有任何成果。
-
java.lang.OutOfMemoryError:Direct buffer memory
Netty + NIO:这是由于NIO引起的
写NIO程序的时候经常会使用ByteBuffer来读取或写入数据,这是一种基于通道(Channel) 与 缓冲区(Buffer)的I/O方式,它可以使用Native 函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
ByteBuffer.allocate(capability)
:第一种方式是分配JVM堆内存,属于GC管辖范围,由于需要拷贝所以速度相对较慢ByteBuffer.allocteDirect(capability)
:第二种方式是分配OS本地内存,不属于GC管辖范围,由于不需要内存的拷贝,所以速度相对较快
但如果不断分配本地内存,堆内存很少使用,那么JVM就不需要执行GC,DirectByteBuffer对象就不会被回收,这时候堆内存充足,但本地内存可能已经使用光了,再次尝试分配本地内存就会出现OutOfMemoryError,那么程序就奔溃了。
一句话说:本地内存不足,但是堆内存充足的时候,就会出现这个问题
-
java.lang.OutOfMemoryError:unable to create new native thread
不能够创建更多的新的线程了,也就是说创建线程的上限达到了,在高并发场景的时候,会应用到
高并发请求服务器时,经常会出现如下异常
java.lang.OutOfMemoryError:unable to create new native thread
,准确说该native thread异常与对应的平台有关导致原因:
- 应用创建了太多线程,一个应用进程创建多个线程,超过系统承载极限
- 服务器并不允许你的应用程序创建这么多线程,linux系统默认运行单个进程可以创建的线程为1024个,如果应用创建超过这个数量,就会报
java.lang.OutOfMemoryError:unable to create new native thread
解决方法:
- 想办法降低你应用程序创建线程的数量,分析应用是否真的需要创建这么多线程,如果不是,改代码将线程数降到最低
- 对于有的应用,确实需要创建很多线程,远超过linux系统默认1024个线程限制,可以通过修改linux服务器配置,扩大linux默认限制
-
java.lang.OutOfMemoryError:Metaspace
元空间内存不足,Matespace元空间应用的是本地内存
追问:OutOfMemoryError和StackOverflowError是异常还是错误?
OutOfMemoryError和StackOverflowError是属于Error,不是Exception
谈谈你了解的垃圾收集器
Serial收集器:串行回收
Serial收集器不仅是一个单线程工作的收集器,并且它在进行垃圾收集时必须暂停其他所有工作线程,直到它收集结束。
Serial收集器作为HotSpot中Client模式下的默认新生代垃圾收集器。
使用到的垃圾收集算法:
Serial收集器收集新生代,采用复制算法、串行回收和"Stop-the-World"机制的方式执行内存回收。
优势:
- Serial垃圾收集器有着由于其他收集器的地方,那就是简单高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器中额外内存消耗最小的;
- 对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集,所以可以获得最高的单线程收集效率。
ParNew收集器:并行回收
ParNew收集器则是Serial收集器的多线程版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数(例如:-XX:SurviorRatio、-XX:PretenureSizeThreshold、-XX:HandlePrommotionFailure等)、收集算法、STW、对象分配规则、回收策略等都跟Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。
ParNew 是很多JVM运行在Server模式下新生代的默认垃圾收集器
使用的垃圾收集算法
ParNew负责收集新生代采用复制算法、并行回收和"Stop-the-World"机制的方式执行内存回收。
优势:
ParNew收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
补充:
直到CMS收集器出现在巩固了ParNew的地位,因为在CMS刚出的使用只能选择ParNew或者Serial收集器中的一个,所以自然并行的ParNew就成为了CMS配套的首选垃圾收集器了,但是从JDK9开始随着G1的出现ParNew+CMS就不再是官网推荐的垃圾收集器解决方案,并且在以后ParNew 和CMS都只能搭配使用了(官方取消了ParNew+Serial Old,Serial + CMS这两种组合),ParNew合并入了CMS
追问:能否断定ParNew收集器的回收效率在任何场景下都会比Serial收集器更高效?
不能
- ParNew收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
- 但是在单个CPU的环境下,ParNew收集器不比Serial收集器更高效。虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。
Parallel Scavenge收集器:吞吐量优先的并行回收
Parallel Scavenge收集器用于收集新生代,同样也采用了复制算法、并行回收和"Stop the World"机制。
Parallel Scavenge收集器的目标是尽可能达到一个可控的吞吐量,它也被称为吞吐量优先的垃圾收集器。自适应调节策略也是Parallel Scavenge与ParNew一个重要区别。(动态调整内存分配情况,以达到一个最优的吞吐量或低延迟)
高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是:
-XX:MaxGCPauseMillis
参数:允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。
垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生的更频繁,原来10s收集一次、每次停顿100毫秒,现在变成5s收集一次、每次停顿70毫秒。停顿时间的确在下降,但是吞吐量却降下来了。
-XX:GCTimeRatio
参数:该参数的值应设置为一个正整数,表示用户期望虚拟机消耗在GC上的时间不超过程序运行时间的1/(1+N)
。默认值为99,含义是尽可能保证应用程序执行的时间为收集器执行时间的99倍,也即收集其的时间消耗不超过总运行时间的1%
Parallel收集器还提供了一个自适应调节策略,用于自动根据当前系统的运行情况收集性能监控信息来动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这个参数是-XX+UseAdaptiveSizePolicy
,注意这是个开关参数,当这个参数被激活后,就不需要人工置顶新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurivorRatio)、今生老年代对象大小(-XX:PretenureSizeThreshold)等参数的细节了。
Serial Old收集器
Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在JDK5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS说机器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。
工作流程如图所示:
Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并行收集,基于标记整理算法实现。这个收集器是直到JDK6时才开始提供的,再次之前,新生代的Parallel Scavenge收集器一直处于相当尴尬的状态,原因是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作。由于老年代Serial Old收集器在服务端应用性能上的“拖累”,使用Parallel Scavenge收集器也未必能在整体上获得吞吐量最大化的效果。同样,由于单线程的老年代收集中无法充分利用服务器多处理器的并行处理能力,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的总吞吐量甚至不一定比ParNew+CMS的组合更加优秀。
直到Parallel Old收集器出现之后,“吞吐量优先”的收集器终于有了比较名副其实的搭配组合了,有注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge+Parallel Old这对组合。
ParallelOld收集器的工作过程如下图:
CMS收集器:低延迟
CMS说机器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常会较为关注服务的相应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。
CMS收集器是基于标记清除算法实现的,它的运作过程划分为四个步骤:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
其中初始标记、重新标记这两个步骤仍需要“STW”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段较长一些,但是也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
由于在整个过程中好事最长的并发标记阶段和并发清除阶段中,垃圾收集器都可以与用户线程一起工作,所以总体上来说,CMS说机器的内存回收过程是与用户线程一起并发执行的。
CMS的运作步骤如下图
追问:谈谈CMS垃圾收集器的缺点:
-
CMS收集器对处理器资源非常敏感。
在并发阶段,虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说处理器的计算能力)而导致用户线程变慢,降低总吞吐量。CMS默认启动的回收线程数是(处理器核心线程数量+3)/4,也就是说,如果处理器核心数在4个或者以上并发回收时垃收集线程值占用不少于25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足4个时,CMS对用户程序的影响就可能变得很大。如果应用本来的处理负载就很高,还要分出一般的运算能力区执行垃圾收集线程,就可能导致用户程序的执行速度忽然大幅度降低。
-
CMS收集器无法处理浮动垃圾,有可能出现"Concurrent Mode Failure"失败进而导致一次完全STW的Full GC的产生
在CMS的并发标记和并发清除阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随着有新的垃圾对象不断产生,但这一步垃圾对象是出现在标记过程结束以后,CMS无法在当次的垃圾收集中处理掉它们,只要留着等待下一次垃圾收集时再清理掉。这一部分垃圾就是“浮动垃圾”
同样也是由于在垃圾收集阶段用户线程还需要在持续运行,那就还需要预留足够内存空间供给用户线程使用,因此CMS说机器不能向其他线程那样等待老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运行使用。在JDK6时,CMS收集器的垃圾收集的启动阈值的默认值为92%。这会面临一种风险:要是CMS运行期间的预留内存无法满足程序分配新对象的需要,就会出现一次“并发失败(Concurrent Mode Failure)”,这时候虚拟机将不得不启动后备预案:冻结用户用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。
通过
-XX:CMSInitiatingOccupancyFraction
设置这个CMS收集的一个阈值,如果设置的过高会很容易导致大量的并发失败产生,性能反而降低。 -
CMS收集器因为采用标记清除算法实现,所以会产生大量的内存空间的碎片。空间碎片过多,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但是就无法找到足够大的连续内存空间来分配当前对象,而不得不提前触发一次Full GC
为了解决这个问题,CMS收集器提供了一个
-XX:UseCMSCompactAtFullCollection
开关参数(默认开启,jdk9被标记为废弃)。用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理,由于这个内存整理必须移动存活对象,(在Shenandoah和ZGC出现前)是无法并发的。这样空间碎片问题是解决了,但停顿时间又会比较长。因此虚拟机设置这还提供了另一个参数-XX:CMSFullGCsBeforeCompaction(此参数从jdk9开始废弃),这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进性碎片整理(默认为0,表示每次进入Full GC时都进行碎片整理)
G1垃圾收集器:区域化分代式
Garbage First(简称G1)收集器是面向局部收集的设计思路和基于Region的内存布局形式。
G1是能够建立起“停顿预测模型”的收集器,停顿预测模型的意思是能够支持指定在一个长度为M毫秒的片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。
G1面向堆内任何部分来组成回收集(Collection Set,简称CSet)进行回收,衡量标准不再是它属于那个分代,而是哪块内存中存放的垃圾数量多,回收收益最大,这就是G1收集器的Mixed GC模式。
虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不在坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间、或者老年代空间。G1收集器能够对扮演不同角色的Region采用不同的策略区处理,这样无论是新建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
G1从整体上来看是基于“标记整理”算法实现的收集器,但动局部(两个Region之间)上看又是基于”标记复制”算法实现的,无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片。
Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过Region容量的一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize
设定,取值范围为1MB~32MB,且应为2的N次幂。而且对于超过了整个Region容量的超级大对象,将会被存放再N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region当作老年代来看待。
示意图如下:
虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,他们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立起停顿预测模型,思路是让G1收集器去追踪各个Region里面的垃圾堆积“价值大小”,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis
指定,默认值是200毫秒),优先处理回收价值收益最大的Region,这也就是“Garbage First”名字由来的原因。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高效的收集效率
G1中提供了三种垃圾回收模式:YoungGC、Mixed GC和Full GC,在不同的条件下被触发。
G1收集器的运作过程如下:
- 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针(指针碰撞法中间的指针)的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但是耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停滞
- 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但是可与用户程序并发执行。当对象图扫描完以后,还需要重新处理SATB(原始快照)记录下的在并发时有引用变动的对象。
- 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录
- 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来指定回收计划,可以自由选择任意多个Region构成回收集,然后再决定回收的哪一部分Region的存活对象复制到空的Region中,再清理掉整个旧的Region的全部空间。这里的操作设计存活对象的移动,是必须暂停用户线程,由多条垃圾收集线程并行完成的。
上述阶段可以看出来G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之它并非纯粹的追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量。
追问:如果将-XX:MaxGCPauseMillis设置的过低会导致什么情况?
毫无疑问,可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。不过,这里设置的“期望值”必须符合实际,毕竟G1是要冻结用户线程来复制对象的,这个停顿时间再怎么低也要有个限度。它默认的停顿目标为两百毫秒,一般来说,回收阶段占到几十到一百甚至接近两百毫秒都很正常,但如果我们把停顿时间调的非常低,比如设置为二十毫秒,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器的分配的速度,导致垃圾慢慢堆积。很可能一开始收集器还能从空闲的堆内存中获得一些喘息时间但应用运行时间一长就不行了,最终沾满堆引发Full GC反而降低性能,所以通常把期望停顿时间设置为一两百毫秒会是比较合理的。
追问:G1的优缺点有哪些?
G1的优点
有很多,可以指定最大停顿时间、分Region的内存布局、按收益动态确定回收集这些创新设计。G1从整体上来看是基于“标记整理”算法实现的收集器,但动局部(两个Region之间)上看又是基于”标记复制”算法实现的,所以G1在运算期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存,这种特性有利于程序长时间运行,在程序为大对象分配内存是不容易因无法找到连续内存空间而提前触发下一次收集。
G1的弱项
也有不少,如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都比CMS要高。
根据经验:目前在小内存上应用CMS的表现大概率仍会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优势的Java堆容量平衡带你通常在6GB至8GB之间。
追问:将Java堆分成多个独立Region后,Region里面存在跨Region引用对象如何解决?
首先使用记忆集避免全堆作为GC Roots扫描,但是在G1收集器上记忆集的应用其实要复杂很多,它每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页范围内。
G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。
G1收集器要比其他传统的垃圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量的10%至20%的额外内存来维持收集器的工作。
追问:在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
这里首先要解决的是用户线程改变对象引用关系时,必须要整其不能打破原本的对象图结构,导致标记出现错误,在G1收集器通过原始快照算法(SATB)来实现不打破原本的对象图结构。
此外垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象分配,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认存活的,不纳入回收范围。如果内存回收的速度盖不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而长时间STW
每个Region都是通过指针碰撞来分配空间
示意图如下:
追问:如何建立起可靠的停顿预测模型?
用户通过-XX:MaxGCPauseMillis
参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但G1收集器怎么做才能满足用户的期望呢?G1收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里面的藏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的“衰减平均值”是指他会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确的代表“最近的”平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成会收集才可以在不超过期望停顿时间的约束下获取最高的收益
谈谈GC垃圾回收算法和垃圾收集器的关系?
GC算法是内存回收的方法论,垃圾收集器就是算法的落地实现
GC算法主要有以下几种
- 引用计数(几乎不用,无法解决循环引用的问题)
- 复制拷贝(用于新生代)
- 标记清除(用于老年代)
- 标记整理(用于老年代)
因为目前为止还没有完美的收集器出现,更没有万能的收集器,只是针对具体应用最合适的收集器,进行分代收集(那个代用什么收集器)
不同垃圾收集器采用的不同的垃圾收集算法:
Serial收集器
收集新生代采用复制算法,串行回收和"Stop-the-World"机制的方式执行内存回收ParNew收集器
负责收集新生代采用复制算法,并行回收和"Stop-the-World"机制的方式执行内存回收Parallel Scavenge收集器
用于收集新生代,同样也采用了复制算法、并行回收和"Stop the World"机制Serial Old收集器
是Serial收集器的老年代版本,它同样是一个单线程收集器串行回收,使用标记整理算法和STW机制的方式进行内存回收Parallel Old收集器
是Parallel Scavenge收集器的老年代版本,支持多线程并行收集,基于标记整理算法和STW机制的方式进行内存回收CMS收集器
是基于标记清除算法实现的收集老年代,其中在它运作过程中的初始标记、重新标记这两个步骤仍需要“STW”其余过程是采用的并发的方式进行进行的。G1垃圾回收器
将堆内存分割成不同区域,然后并发的进行垃圾回收
参数 | 新生代垃圾收集器 | 新生代算法 | 老年代垃圾收集器 | 老年代算法 |
---|---|---|---|---|
-XX:+UseSerialGC | Serial | 复制 | Serial Old | 标记整理 |
-XX:+UseParNewGC | ParNew | 复制 | Serial Old | 标记整理 |
-XX:+UseParallelGC | Parallel [Scavenge] | 复制 | Parallel Old | 标记整理 |
-XX:+UseConcMarkSweepGC | ParNew | 复制 | CMS + Serial Old的收集器组合,Serial Old作为CMS出错的后备收集器 | 标记清除 |
-XX:+UseG1GC | G1整体上采用标记整理算法 | 局部复制 |
垃圾收集器的组合关系是什么样的?
- 两个收集器间有连线,表明它们可以搭配使用:Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
- 其中Serial Old作为CMS出现" Concurrent Mode Failure "失败的后备预案。
- (红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、ParNew+Serial Old这两个组合声明为废弃(JEP173),并在JDK9中完全取消了这些组合的支持(JEP214),即:移除。
- (绿色虚线)JDK14中:弃用Parallel Scavenge和Serialold GC组合(JEP366)
- (青色虚框)JDK14中:删除CMS垃圾回收器(JEP363)
怎么查看服务器默认的垃圾收集器是哪个?
使用下面JVM命令,查看配置的初始参数
-XX:+PrintCommandLineFlags
然后运行一个程序后,能够看到它的一些初始配置信息
-XX:InitialHeapSize=266376000 -XX:MaxHeapSize=4262016000 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
移动到最后一句,就能看到 -XX:+UseParallelGC
说明使用的是并行垃圾回收
-XX:+UseParallelGC
可以看到jdk8默认的垃圾收集器为: ParallelGC 和 ParallelOldGC 的组合
生产上如何配置垃圾收集器
- 单CPU或者小内存,单机程序
- -XX:+UseSerialGC
- 多CPU,需要最大的吞吐量,如后台计算型应用
- -XX:+UseParallelGC(这两个相互激活)
- -XX:+UseParallelOldGC
- 多CPU,追求低停顿时间,需要快速响应如互联网应用
- -XX:+UseConcMarkSweepGC
- -XX:+ParNewGC