JVM探究
JVM的位置
首先我们来看看 JVM 在我们整个系统的位置:
JVM是运行在操作系统之上的,它与硬件没有直接的交互。
JVM体系结构图
如果你不能够闭着眼睛画出 JVM 的体系结构图,说明你还没有入门 JVM:
分析:这个区域一定不会有垃圾回收
所谓JVM的调优,其实就是在调方法区和堆区,而且99%情况下都在调堆 !
基本加载流程
- 首先Java源代码文件(.java文件)会被Java编译器编译为字节码文件(.class文件);
- 然后由JVM中的类加载器加载各个类的字节码文件;
- 加载完毕之后,交由JVM执行引擎执行;
在整个程序执行过程中,JVM会用一段空间来存储程序执行期间需要用到的数据和相关信息,这段空间一般被称作为Runtime Data Area(运行时数据区),也就是我们常说的JVM内存,在Java中我们常常说到的内存管理就是针对这段内存空间进行管理(如何分配和回收内存空间)。
执行引擎
分配给运行时数据区的字节码将由执行引擎执行,执行引擎读取字节码并逐段执行。解释器:
- 解释器能快速地解释字节码,但执行却很慢。
- 解释器的缺点就是,当一个方法被调用多次,每次都需要重新解释。把程序源代码一行一行的读懂然后执行,发生在运行时,产物是「运行结果」。
- JIT编译器:
- 把整个程序源代码翻译成另外一种代码,然后等待被执行,发生在运行之前,产物是「另一份代码」。
- JIT编译器消除了解释器的缺点。执行引擎利用解释器转换字节码,但如果是重复的代码则使用JIT编译器将全部字节码编译成本机代码。本机代码将直接用于重复的方法调用,这提高了系统的性能。
- 中间代码生成器 – 生成中间代码
- 代码优化器 – 负责优化上面生成的中间代码
- 目标代码生成器 – 负责生成机器代码或本机代码
- 探测器(Profiler) – 一个特殊的组件,负责寻找被多次调用的方法。
- 垃圾回收器:
- 收集并删除未引用的对象。可以通过调用”
System.gc()
“来触发垃圾回收,但并不保证会确实进行垃圾回收。 - JVM的垃圾回收只收集那些由new关键字创建的对象。所以,如果不是用new创建的对象,可以使用finalize函数来执行清理。
- 收集并删除未引用的对象。可以通过调用”
- Java本地接口 (JNI):
- JNI 会与本地方法库进行交互并提供执行引擎所需的本地库。
本地方法库:
执行了System.exit()方法
- 程序正常执行结束
- 程序在执行过程中遇到了异常或者错误而异常终止
- 由于操作系统出现错误而导致Java虚拟机进行终止
类的加载、连接与初始化
在Java代码中,Class类的加载、连接与初始化过程都是在程序运行期间完成的。
- 加载: 查找并加载类的二进制数据
- 连接
- 验证:确保被加载的类的正确性
- 准备:为类的静态变量分配内存,并将其初始化为默认值
- 解析:把类中的符号引用转换为直接引用
- 在编译时每个java类都会被编译成一个class文件,但编译时虚拟机并不知道所引用类的地址,所以就用符号引用来代替,而这个解析阶段就是为了把这个符号引用转化成真正的地址的阶段。
- 初始化:为类的静态变量赋予正确的初始值
从代码来理解:
class Test{
public static int a = 1;
}
/*
我们程序中给定的是 public static int a = 1;
但是在加载过程中的步骤如下:
1. 加载阶段:将.java文件编译文件为.class文件,然后通过类加载,加载到JVM
2. 连接阶段
第一步(验证):确保Class类文件没问题
第二步(准备):先初始化为 a=0。(因为int类型的初始值为0)
第三步(解析):将引用转换为直接引用
3. 初始化阶段:通过此解析阶段,把1赋值为变量a
*/
类的加载
类的加载指的是将类的.class文件中二进制数据读入到内存中,将其放在运行时数据区内的方法区内,然后在内存中创建一个java.lang.Class 对象用来封装类在方法区内的数据结构。
//对于静态字段来说,只有直接定义了该字段的类才会被初始化;
//当一个类在初始化时,要求其父类全部都已经初始化完毕了;
//所有Java虚拟机实现必须在每个类或者接口被Java程序“首次主动使用”时才初始化他们
public class MyTest1 {
public static void main (String[] args){
System.out.println(MyChild1.str2);
}
}
class MyParent1{
public static String str = "hello world";
static {
System.out.println("MyParent1 static");
}
}
class MyChild1 extends MyParent1{
public static String str2 = "welcome";
static{
System.out.println("MyChild1 static");
}
}
// 输出结果:MyParent1 static block MyChild1 static block welcome
查看类的加载信息,并打印出来:
jvm 参数介绍:
-XX:+TraceClassLoading,用于追踪类的加载信息并打印出来。
所有的参数都是:
-XX:+<option> , 表示开启option选项
-XX:-<option> , 表示关闭option选项
-XX:+<option>=<value> 表示将option选项的值设置为value
常量池的概念
我们先来看一道题:
public class MyTest2{
public static void main(String[] args){
System.out.println(MyParent2.str);
}
}
class MyParent2{
public static final String str = "hello world";
static {
System.out.println("Myparent2 static block");// 这一行能输出吗?不会
}
}
/*
常量在编译阶段会存入到调用这个常量的方法所在的类的常量池中。
本质上,调用类并没有直接用到定义常量的类,因此并不会触发定义常量的类的初始化。
注意:这里指的是将常量存放到了MyTest2的常量池中,之后MyTest2与MyParent2就没有任何关系
了。
*/
再看一道题,类的初始化规则:
/*
当一个常量的值并非编译期间可以确定的,那么其值就不会被放到调用类的常量池中,
这是在程序运行时,会导致主动使用这个常量所在的类,显然就会导致这个类被初始化。
*/
public class MyTest3{
public static void main(String[] args){
System.out.println(MyParent3.str);
}
}
class MyParent3{
public static final String str = UUID.randomUUID().toString();
static {
System.out.println("Myparent3 static block"); // 这一行能输出吗?会
}
}
//为什么第二个例子不会输出,第三个例子就输出了呢?
//因为第三个例子的值,是只有当运行期才会被确定的值。
//而第二个例子的值,是编译时就能被确定的值。
ClassLoader分类
有两种类型的类加载器
- Java虚拟机自带的加载器
- 根类加载器(BootStrap)—> (BootClassLoader) sun.boot.class.path
- 加载系统的包,包含jdk核心库里的类
- 扩展类加载器(Extension)—>(ExtClassLoader) java.ext.dirs
- 加载扩展jar包中的类
- 系统(应用)类加载器(System)—> (AppClassLoader) java.class.path
- 加载你编写的类、编译后的类
- 根类加载器(BootStrap)—> (BootClassLoader) sun.boot.class.path
- 用户自定义的类加载器
- Java.long.ClassLoader的子类(继承),用户可以定制类的加载方式
代码测试
public class ClassLoaderDemo01 {
public static void main(String[] args) {
Object object = new Object();
ClassLoaderDemo01 demo01 = new ClassLoaderDemo01();
System.out.println(object.getClass().getClassLoader());
System.out.println(demo01.getClass().getClassLoader());
System.out.println(demo01.getClass().getClassLoader().getParent());
System.out.println(demo01.getClass().getClassLoader().getParent().getParent());
/*
结果:
null
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1b6d3586
null
**/
}
}
双亲委派机制
Java 双亲委派模型机制
Java是运行在JVM中的,编写的Java源代码会被编译器编译成.class文件形式的字节码文件,然后ClassLoader负责将这些class文件给加载到JVM中去执行。
JVM中提供了三层的ClassLoader:
- Bootstrap classLoader:主要负责加载核心的类库(java.lang.*等),构造ExtClassLoader和APPClassLoader。
- ExtClassLoader:主要负责加载jre/lib/ext目录下的一些扩展的jar。
AppClassLoader:主要负责加载应用程序的主函数类。
双亲委派机制
通俗点讲,双亲可以浅显理解为父类,当.class文件将要被加载到JVM中时,会先交由AppClassLoader去加载,这时目前的ClassLoader不会去加载,而是一直向上,让父类去加载。
如果一个类加载器收到了类加载请求,首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。
- 每一个层次的类加载器都是如此。因此,所有的加载请求最终都应该传送到顶层的启动类加载器中。
- 只有当父加载器反馈自己无法完成这个加载请求时(搜索范围中没有找到所需的类),子加载器才会尝试自己去加载。
双亲委派机制的工作原理:一层一层地让父类去加载,最顶层父类不能加载就往下数,依次类推。
- 类加载器收到类加载的请求;
- 把这个请求委托给父类加载器去完成,一直向上委托,直到启动类加载器;
- 启动类加载器检查能不能加载(使用findClass()方法),能加载就加载(结束);否则,抛出异常,通知子加载器进行加载。
- 重复步骤三;
系统的ClassLoader只会加载指定目录下的class文件,如果想加载自己的class文件,那么就可以自定义一个ClassLoader。而且我们可以根据自己的需求,对class文件进行加密和解密。
需要注意的是,自定义加载器在“优先级”上,是最低的,所以简单来讲,自定义的classLoader去加载自己的类,一定是父级们都没有办法加载的情况下。
Why 双亲委派
- 通过双亲委派模型机制,类随着它的类加载器一起具备了一种带有优先级的层次关系。
- 双亲委派这种机制能够避免核心类被篡改。
- 例如:如果有人想替换系统级别的类:String.java。篡改它的实现,在这种机制下这些系统的类已经被Bootstrap classLoader加载过了(为什么?因为当一个类需要加载的时候,最先去尝试加载的就是BootstrapClassLoader),所以其他类加载器并没有机会再去加载,从一定程度上防止了危险代码的植入。
- 再比如,如果没有使用双亲委派机制,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,在没有双亲委派模型的情况下,将会用自定义的类加载器加载,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。
-
打破双亲委派模型的历史
第一次破坏
由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类java.lang.ClassLoader则在JDK1.0时就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。
在此之前,用户去继承java.lang.ClassLoader的唯一目的就是为了重写loadClass()方法,这是因为虚拟机在进行类加载时会调用加载器的私有方法loadClassInternal(),而该方法的唯一逻辑就是去调用自己的loadClass(),用户重写了loadClass才能实现自己的类加载逻辑。
- 第二次破坏
双亲委派模型的第二次“被破坏”是由模型自身的缺陷所导致的,双亲委派很好地解决了各个类加载器的基础类的同一问题:越基础的类由越上层的加载器进行加载。基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API。
如果基础类又要调用回用户的代码,那该么办?
一个典型的例子就是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器去加载(在JDK1.3时放进去的rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者的代码,但启动类加载器不可能“认识”这些代码。
为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,他将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
有了线程上下文加载器,JNDI服务就可以使用它去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。
- 第三次破坏
双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求导致的,与热部署相关,这里所说的“动态性”指的是当前一些非常“热门”的名词:代码热替换、模块热部署等,简答地说就是机器不用重启,只要部署上就能用。
代码测试:
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println(1);
}
}
String类默认情况下是启动类加载器进行加载的。
假设自定义一个String ,自定义的String 可以正常编译,但是永远无法被加载运行。这是因为申请自定义String 加载时,总是启动类加载器,而不是自定义加载器,也不会是其他的加载器。
双亲委派机制可以确保Java核心类库所提供的类,不会被自定义的类所替代。
沙箱安全机制
什么是沙箱?
Java安全模型的核心就是Java沙箱(sandbox)。
- 沙箱是一个限制程序运行的环境。
- 沙箱机制就是将 Java 代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。
- 沙箱主要限制系统资源访问,限制的系统资源包括CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。
-
java中的安全模型
在Java中将执行程序分成本地代码和远程代码两种,本地代码默认视为可信任的,而远程代码则被看作是不受信的。对于授信的本地代码,可以访问一切本地资源。而对于非授信的远程代码在早期的Java实现中,安全依赖于沙箱 (Sandbox) 机制。如下图所示
JDK1.0安全模型 JDK1.1安全模型
但如此严格的安全机制也给程序的功能扩展带来障碍,比如当用户希望远程代码访问本地系统的文件时候,就无法实现。因此在后续的 Java1.1 版本中,针对安全机制做了改进,增加了安全策略,允许用户指定代码对本地资源的访问权限。如下图所示
JDK1.1安全模型
- JDK1.2安全模型
在 Java1.2 版本中,再次改进了安全机制,增加了代码签名。不论本地代码或是远程代码,都会按照用户的安全策略设定,由类加载器加载到虚拟机中权限不同的运行空间,来实现差异化的代码执行权限控制。如下图所示
JDK1.2安全模型
- 最新的安全模型
当前最新的安全机制实现,则引入了域 (Domain) 的概念。虚拟机会把所有代码加载到不同的系统域和应用域,系统域部分专门负责与关键资源进行交互,而各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问。虚拟机中不同的受保护域 (Protected Domain),对应不一样的权限 (Permission)。存在于不同域中的类文件就具有了当前域的全部权限,如下图所示
最新的安全模型
以上提到的都是基本的 Java 安全模型概念,在应用开发中还有一些关于安全的复杂用法,其中最常用到的 API 就是 doPrivileged。doPrivileged 方法能够使一段受信任代码获得更大的权限,甚至比调用它的应用程序还要多,可做到临时访问更多的资源。
有时候这是非常必要的,可以应付一些特殊的应用场景。例如,应用程序可能无法直接访问某些系统资源,但这样的应用程序必须得到这些资源才能够完成功能。
组成沙箱的基本组件
- 字节码校验器(bytecode verifier)
- 它保证java代码符合java语言规范,这样可以帮助Java程序实现内存保护。
- 但并不是所有的类文件都会经过字节码校验,比如核心类由于已经校验过封装好的,字节码不会校验核心类。
- 类加载器(class ladder)
- 类加载器采用的是双亲委派机制。
- 类加载器在3个方面对Java沙箱起作用:
- 防止恶意代码去干扰善意的代码,保证了好的代码不会被坏的代码污染;
- 它守护了被信任类库的边界;
- 它将代码归入保护域,确定了代码可以进行哪些操作。
虚拟机为不同的类加载器载入的类提供不同的命名空间,命名空间由一系列唯一的名称组成,每一个被加载的类将有一个名字,这个命名空间是由Java虚拟机为每一个类加载器维护的,它们相互之间甚至不可见。
- 从最内层JVM自带的类加载器开始加载,外层恶意同名类得不到加载而无法使用;
- 由于严格通过包来区分了访问域,外层恶意的类通过内置代码也无法获得权限访问到内层类,破坏代码就自然无法生效。
- 存取控制器(access controller):存取控制器可以控制核心API对操作系统的存取权限,而这个控制的策略设定,可以由用户指定。
- 安全管理器(security manager):是核心API和操作系统之间的主要接口。实现权限控制,比存取控制器优先级高。
- 安全软件包(security package):java.security下的类和拓展包下的类,允许用户为自己的应用增加新的安全特性,包括:
- 安全提供者
- 消息摘要
- 数字签名
- 加密
- 鉴别
Native方法
编写一个多线程类启动
public static void main(String[] args) {
new Thread(()->{
},"your thread name").start();
}
点进去看start方法的源码
public synchronized void start() {
if (threadStatus != 0)
throw new IllegalThreadStateException();
group.add(this);
boolean started = false;
try {
start0(); //调用了一个start0方法
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
}
}
}
//这个Thread是一个类,这个方法定义在这里是不是很诡异!看这个关键字native;
private native void start0();
- 凡是带了native关键字的,说明 java的作用范围达不到,会去调用底层C语言的库。
- 凡是带了native关键字的方法就会进入本地方法栈。
JNI:Java Native Interface (Java本地方法接口)
Native Method Stack 本地方法栈
- JNI作用:拓展Java的使用,融合不同的编程语言为Java所用。
- 它的初衷是融合C/C++程序,Java诞生时是C/C++横行的时候,想要立足,必须有调用C/C++的程序,于是就在内存中专门开辟了一块区域处理标记为native的代码,它的具体做法是在 Native Method Stack (本地方法栈)中登记native方法, 在 ( Execution Engine ) 执行引擎执行的时候加载Native Libraies。
目前该方法使用的越来越少了,除非是与硬件有关的应用。比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间通信很发达,比如可以使用Socket通信,也可以使用Web Service等等。
PC计数器(程序计数器)
程序计数器:Program Counter Register
每个线程都有一个程序计数器,是线程私有的。
程序计数器是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。
在虚拟机的概念模型里字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成,是一个非常小的内存空间,几乎可以忽略不计。public class Calc {
public int calc(){
int a = 100;
int b = 200;
int c = 300;
return ( a + b ) * c;
}
}
反编译: Javap -c xx.class 反编译之后会有助记符。
ldc :表示将int、float或String类型的常量值从常量池中推送至栈顶。
bipush :表示将单字节(-128~127)的常量值推送至栈顶。
sipush :表示将短整型(-32767~32768)的常量值推送至栈顶。
istore_1 :将一个数值从操作数栈存储到局部变量表。
iadd :加
imul :乘
图中使用红框框起来的就是字节码指令的偏移地址,偏移地址对应的bipush 等等是jvm 中的操作指令,这是入栈指令。 当执行到方法calc()时在,当前线程中会创建相应的程序计数器,在计数器中为存放执行地址 (红框中的)0 2 3…等等方法区
方法区是被所有线程共享的,所有字段、方法、字节码及一些特殊方法,如构造函数、接口代码也在此定义。简单地说,所有定义的方法信息都保存在该区域,此区域属于共享区间。
- Method Area 方法区是Java虚拟机规范中定义的运行时数据区域之一,它与堆(heap)一样在线程之间共享。
- Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
- 静态变量(static)、常量(final)、类信息Class(构造方法、接口定义)、运行时的常量池存在方法区中,但是实例变量存在堆内存中,和方法区无关。
JDK7 之前永久代用于存储已被虚拟机加载的类信息、常量、字符串常量、类静态变量、即时编译器编译后的代码等数据。每当一个类初次被加载的时候,它的元数据都会被放到永久代中。永久代大小有限制,如果加载的类太多,很可能导致永久代内存溢出,即 java.lang.OutOfMemoryError: PermGen。
JDK8 彻底将永久代移除出 HotSpot JVM,将其原有的数据迁移至 Java Heap 或 Native Heap(Metaspace),取代它的是另一个内存区域被称为元空间(Metaspace)。
- 元空间(Metaspace):元空间是方法区在 HotSpot JVM 中的实现,方法区主要用于存储类信息、常量池、方法数据、方法代码、符号引用等。
- 元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。
- 不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
- 可以通过 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 配置内存大小。
如果Metaspace的空间占用达到了设定的最大值,那么就会触发GC来收集死亡对象和类的加载器。
栈(Stack)
栈和队列
栈:后进先出 / 先进后出
- 队列:先进先出(FIFO : First Input First Output)
Stack 栈
- 栈管理程序运行:存储一些基本类型的值、对象的引用、方法等。
- 栈的优势是,存取速度比堆要快,仅次于寄存器,栈数据可以共享。
思考:为什么main方法最后执行!为什么?
- main()方法先入栈,test()方法再入栈;
- 结束时,test()方法先出栈,main()方法最后出栈。
说明:
- 栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,它的生命周期是跟随线程的生命周期,线程结束时栈内存也就释放了。
- 对于栈来说不存在垃圾回收问题,只要线程一旦结束,该栈就Over,生命周期和线程一致,是线程私有的。
- 栈里面存放:8大基本类型、对象引用、实例方法
- 方法自己调自己就会导致栈溢出
递归死循环测试
public class StackDemo {
public static void main(String[] args) {
a();
}
public static void a(){
b();
}
public static void b(){
a();
}
}
栈运行原理
Java栈的组成元素—栈帧
- 栈帧是一种用于帮助虚拟机执行方法调用与方法执行的数据结构。
- 他是独立于线程的,一个线程有自己的一个栈帧;栈中封装了方法的局部变量表、动态链接信息、方法的返回地址以及操作数栈等信息。
第一个方法从调用开始到执行完成,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
当一个方法A被调用时就产生了一个栈帧F1,并被压入到栈中,A方法又调用了B方法,于是产生了栈帧F2也被压入栈中,B方法又调用了C方法,于 是产生栈帧F3也被压入栈中………执行完毕后,先弹出F3,然后弹出F2,在弹出F1……..
遵循 “先进后出” / “后进先出” 的原则。
栈、堆、方法区的交互关系
Java对象实例化过程
1.对象的实例化过程
- 对象的实例化过程是分成两部分:类的加载初始化、对象的初始化
- 要创建类的对象实例需要先加载并初始化该类,main方法所在的类需要先加载和初始化。
- 类的初始化就是执行
方法,对象实例化是执行 方法 -
2.类的加载过程
类的加载机制:如果没有相应类的class,则加载class到方法区。对应着加载 -> 验证 -> 准备 -> 解析 -> 初始化阶段
- 加载:载入class对象,不一定是从class文件获取,可以是jar包,或者动态生成的class。
- 验证:校验class字节流是否符合当前jvm规范。
- 准备:为类变量分配内存并设置变量的初始值(默认值),如果是final修饰的对象则是赋值声明值。
- 解析:将常量池的符号引用替换为直接引用。
- 初始化:执行类构造器
(注意不是对象构造器),为类变量赋值,执行静态代码块。jvm会保证子类的 执行之前,父类的 先执行完毕。
- 其中验证、准备、解析3个部分称为连接
方法由静态变量赋值代码和静态代码块组成;先执行类静态变量显示赋值代码,再到静态代码块代码。 3.触发类加载的条件
第一次创建类的新对象时,会触发类的加载初始化和对象的初始化函数
执行,这个是实例初始化,其他6个都是类初始化. - JVM启动时会先加载初始化包含main方法的类
- 调用类的静态方法(如执行invokestatic指令)
- 对类或接口的静态字段执行读写操作(即执行getstatic、putstatic指令),不过final修饰的静态字段除外(已经赋值,String和基本类型,不包含包装类型),它被初始化为一个编译时常量表达式。
- 注意:操作静态字段时,只有直接定义这个字段的类才会被初始化;如通过其子类来操作父类中定义的静态字段,只会触发父类
的初始化而不是子类的初始化
- 注意:操作静态字段时,只有直接定义这个字段的类才会被初始化;如通过其子类来操作父类中定义的静态字段,只会触发父类
- 调用Java API中的反射方法时(比如调用java.lang.Class中的方法(Class.forName),或者java.lang.reflect包中其他类的方法)
当初始化一个类时,其父类没有初始化,则需先触发父类的初始化(接口例外)
4.对象的实例化过程
对象实例化过程 其实就是执行类构造函数对应在字节码文件中的
()方法(称之为实例构造器); ()方法由非静态变量、非静态代码块以及对应的构造器组成 ()方法可以重载多个,类有几个构造器就有几个 ()方法 ()方法中的代码执行顺序为:父类变量初始化 —> 父类代码块 —> 父类构造器 —> 子类变量初始化 —> 子类代码块 —> 子类构造器
- 静态变量、静态代码块、普通变量、普通代码块、构造器的执行顺序
- 具有父类的子类实例化顺序如下
5.类加载器和双亲委派规则,如何打破双亲委派规则
- 类加载器
- 通过一个类的全限定名来获取描述此类的二进制字节流,实现这个动作的代码模块称为类加载器
- 任意一个类都需要其加载器和类本身来确定类在JVM的唯一性;每个类加载器都有自己的类名称空间,同一个类class由不同的加载器加载,则被JVM判断为不同的类。
- 双亲委派模型
- 启动类加载器由C++代码实现,是虚拟机的一部分,负责加载lib下的类库
- 其他的类加载器有java语言实现,独立于JVM,并且继承ClassLoader
- extention ClassLoader负责加载libext目录下的类库
- application ClassLoader 负责加载用户路径下(ClassPath)的代码
- 不同的类加载器加载同一个class文件会导致出现两个类。而java给出解决方法是下层的加载器委托上级的加载器去加载类,如果父类无法加载(在自己负责的目录找不到对应的类),而交还下层类加载器去加载。如下图
打破双亲委派模型
- 双亲委派模型并不是一个强制的约束模型,而是java设计者推荐给开发者的类加载实现方式。
- 双亲委派模型很好地解决了各个类加载基础类的同一问题(越基础的类由越上层的加载器加载),但是基础类总是作为用户代码调用的API,如果它的具体实现是下层的代码,此时基础类需要调用下层的代码,则需要打破双亲委派模型。
- 如JNDI服务,JNDI的代码有启动类去加载(rt.jar),它需要调用由独立厂商部署在应用程序classpath下的JNDI的SPI(Service Provider Interface)代码。为了解决SPI代码加载问题,java引入了线程上下文类加载器去加载SPI代码,也就是父类加载器请求子类去完成类的加载动作。
- 线程上下文类加载器,线程创建时会从父线程继承,如果全局范围没有设置过,则默认设置为application Class Loader
三种JVM
目前三种主流JVM
- SUN
- BEA
-
Sun公司的 HotSpot
Sun公司的HotSpo是大家在开发过程中普遍用的比较多的一个。
- 关于GC回收,SUN的JVM的GC回收由两个部分组成,一个是频繁GC,一个是Full GC。
- 如何提高编写程序的效率:减少FULL GC的次数,如40多分钟一次FULL GC,更好的话就是加大频繁GC一次的回收量。
-
BEA公司的 JRockit
Oracle JRockit (原来的 Bea JRockit)系列产品是一个全面的Java运行时解决方案组合。
- 大量的行业基准测试显示,JRockit JVM是世界上最快的JVM;适合财务前端办公、军事指挥与控制和电信网络的需要。
- JRockit JVM是三种中比较有特点的JVM,性能最强,且对线程和网络都做了大量的优化和技巧的工作。
它的GC机制,跟SUN的GC机制有很大不同:
全称:IBM Technology for Java Virtual Machine,简称IT4J,内部代号:J9
- 市场定位与HotSpot接近,服务器端、桌面应用、嵌入式等多用途VM,广泛用于IBM的各种Java产品
- 目前,有影响力的三大商用虚拟机之一,也号称是世界上最快的Java虚拟机。
- 2017年左右,IBM发布了开源J9 VM,命名为OpenJ9,交给Eclipse基金会管理,也称为Eclipse OpenJ9
- IBM J9直至今天仍旧非常活跃 ,IBM J9虚拟机的职责分离与模块化做得比HotSpot更优秀,由J9 虚拟机中抽象封装出来的核心组件库(包括垃圾收集器、即时编译器、诊断监控子系统等)就单独构成了IBM OMR项目,可以在其他语言平台如Ruby、Python中快速组装成相应的功能。
- 从2016年起, IBM逐步将OMR项目和J9虚拟机进行开源,完全开源后便将它们捐献给了Eclipse基金会管理,并重新命名为Eclipse OMR和OpenJ9。
开发过程中要注意兼容性的问题,听说IBM的JVM现在更新很慢,所以不一定符合新的java标准。
总结
对于一般的应用而言,建议采用SUN的JVM就足够了;
- 对于对性能要求很高的应用而言,建议采用BEA的JVM,如java版的游戏服务器;
对于有钱的公司而言,建议采用IBM的JVM,那是一整套解决方案,后期维护方便。
堆(Heap)
Java7之前
Heap 堆,一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的。
类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行。
堆内存分为三部分:新生区 Young Generation Space — Young/New
- 养老区 Tenure generation space — Old/Tenure
- 永久区 Permanent Space Perm
堆内存逻辑上分为三部分:新生,养老,永久(元空间:JDK8 以后,逻辑上存在,物理上不存在。)
GC垃圾回收主要是在新生区和养老区,又分为 轻GC和重GC,如果内存不够或者存在死循环,就会导致OOM — java.lang.OutOfMemoryError: Java heap space
新生区
- 新生区是类诞生、成长、消亡的区域,一个类在这里产生、应用,最后被垃圾回收器收集,结束生命。
- 新生区又分为两部分:伊甸区(Eden Space)和幸存区(Survivor Space),所有的类都是在伊甸区被new出来的,幸存区有两个:0区和1区。
- 当伊甸区的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸区进行垃圾回收(Minor GC)。将伊甸区中的剩余对象移动到幸存0区,若幸存0区也满了,再对该区进行垃圾回收,然后移动到1区。(这里幸存0区和1区是一个互相交替的过程)
- 如果1区也满了,就再移动到养老区,若养老区也满了,那么这时将产生MajorGC(Full GC),进行养老区的内存清理,若养老区执行了Full GC后发现依然无法进行对象的保存,就会产生OOM异常。
如果出现 java.lang.OutOfMemoryError:java heap space
异常,说明Java虚拟机的堆内存不够,原因如下:
- Java虚拟机的堆内存设置不够,可以通过参数 -Xms(初始值大小),-Xmx(最大大小)来调整。
- 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)或者死循环。
Sun HotSpot内存管理
分代管理,不同的区域使用不同的算法:
Why?
真相:经过研究,不同对象的生命周期不同,在Java中98%的对象都是临时对象。
永久区(Perm)
- 永久存储区是一个常驻内存区域,用于存放JDK自身所携带的Class、Interface的元数据,也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭JVM才会释放此区域所占用的内存。
- 如果出现
java.lang.OutOfMemoryError:PermGen space
,说明是 Java虚拟机对永久代Perm内存设置不够。一般出现这种情况,都是程序启动需要加载大量的第三方jar包,例如:在一个Tomcat下部署了太多的应用或者大量动态反射生成的类不断被加载,最终导致Perm区被占满,就会出现OOM。
注意:
- Jdk1.6之前: 有永久代,常量池在方法区中。
- Jdk1.7: 有永久代,但是已经逐步 “去永久代”,常量池在堆中。
- Jdk1.8及之后:无永久代,常量池在元空间中。
实际而言,方法区(Method Area)和堆一样,是各个线程共享的内存区域,它用于存储虚拟机加载的类信息、普通常量、静态常量和编译器编译后的代码,虽然JVM规范将方法区描述为堆的一个逻辑部分,但它还有一个别名,叫做Non-Heap(非堆),目的就是要和堆分开。
对于HotSpot虚拟机,很多开发者习惯将方法区称之为 “永久代(Parmanent Gen)”,但严格本质上说两者不同,或者说使用永久代实现方法区而已,永久代是方法区(相当于是一个接口interface)的一个实现,Jdk1.7的版本中,已经将原本放在永久代的字符串常量池移走。
常量池(Constant Pool)是方法区的一部分,Class文件除了有类的版本、字段、方法、接口描述信息外,还有一项信息就是常量池,这部分内容将在类加载后进入方法区的运行时常量池中存放!
堆内存调优
先看下 JDK 1.7 的 和 1.8 的区别
JDK 1.8 的
使用 IDEA 调整堆内存大小测试
- 堆内存调优
- -Xms :设置初始分配大小,默认为物理内存的 “1/64”
- -Xmx :最大分配内存,默认为物理内存的 “1/4”
- -XX:+PrintGCDetails :输出详细的GC处理日志
代码测试
public class Demo01 {
public static void main(String[] args) {
//返回Java虚拟机试图使用的最大内存量
long maxMemory = Runtime.getRuntime().maxMemory();
//返回Java虚拟机中的内存总量
long totalMemory = Runtime.getRuntime().totalMemory();
System.out.println("MAX_MEMORY="+maxMemory+"(字节)、"
+(maxMemory/(double)1024/1024)+"MB");
System.out.println("TOTAL_MEMORY="+totalMemory+"(字节)、"
+(totalMemory/(double)1024/1024)+"MB");
}
}
IDEA中进行JVM调优参数设置,然后启动
发现,默认的情况下分配的内存是总内存的 1/4,而初始化的内存为 1/64 !
-Xms1024m -Xmx1024m -XX:+PrintGCDetails
VM参数调优:把初始内存,和总内存都调为 1024M,运行,查看结果!
我们来大概计算分析一下!
再次证明:元空间并不在虚拟机JVM中,而是使用本地内存。
出现OOM:
- 1.尝试扩大堆内存,看结果。
- 2.分析内存,使用专业工具看一下哪个地方出现了问题。
测试二
代码:
public class Demo02 {
public static void main(String[] args) {
String str = "kuangShenSayJava";
while (true){
str += str + new Random().nextInt(88888888)
+new Random().nextInt(999999999);
}
}
}
vm参数:
-Xms8m -Xmx8m -XX:+PrintGCDetails
测试,查看结果!
这是一个young 区撑爆的JAVA 内存日志,其中 PSYoungGen 表示 youngGen分区的变化,1536k 表示 GC 之前的大小,488k 表示GC 之后的大小,整个Young区域的大小从 1536K 到 624K , young代的总大小为 7680K。
[Times: user=0.02 sys=0.00, real=0.01 secs]
- user – 总计本次 GC 总线程所占用的总 CPU 时间
- sys – OS 调用 or 等待系统时间
- real – 应用暂停时间
如果GC 线程是 Serial Garbage Collector 串行搜集器方式的话(只有一条GC线程), real time 等于user 和 system 时间之和。通过日志发现Young区域到最后 GC 之前后都是0,old 区域无法释放,最后报堆溢出错误。
Dump内存快照
在运行java程序时,有时候想测试运行时占用的内存情况,就需要使用测试工具查看了。在eclipse里面有 Eclipse Memory Analyzer tool(MAT) 可以测试,而在idea中也有这么一个插件,就是 JProfiler,一款性能瓶颈分析工具!
作用:
- 分析Dump文件,快速定位内存泄漏;
- 获得堆中对象的统计数据;
- 获得对象相互引用的关系;
- 采用树形展现对象间相互引用的情况;
- ……
而且这个软件跨平台:
安装JProfiler
1、IDEA插件安装
2、安装JProfiler监控软件
下载地址
3、下载完进行安装
4、注册
// 注册码供参考
L-J12-STALKER#5846458-y8bdm6q8gtr7b#228a
5、配置IDEA运行环境
6、选择要分析的项目,点击JProfiler图标启动, 启动完成会自动弹出JProfiler窗口,就可以监控自己的代码性能了。
代码测试
public class Demo03 {
byte[] byteArray = new byte[1*1024*1024]; //1M = 1024K
public static void main(String[] args) {
ArrayList<Demo03> list = new ArrayList<>();
int count = 0;
try {
while (true){
list.add(new Demo03());
count = count + 1;
}
}catch (Error e){
System.out.println("count:"+count);
e.printStackTrace();
}
}
}
vm参数 : -Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
- -Xms //设置初始化内存分配大小,默认1/64
- -Xmx //设置最大分配内存,默认1/4
- -XX:+PrintGCDetails //打印GC垃圾回收信息
- -XX:+HeapDumpOnOutOfMemoryError //OOM DUMP
运行结果:
寻找文件:
使用 Jprofiler 工具分析查看
双击这个文件默认使用 Jprofiler 进行 Open
定位到最大对象:
定位到问题出现的代码行:
GC:垃圾回收
GC 的作用域:方法区(Method Area)、堆(Heap)
记住GC口诀: 分代收集算法
GC算法总体概述
先看下一个对象的历程:
JVM 在进行GC时,并非每次都对上面三个内存区域一起回收,大部分时候回收的都是指新生代。
- 新生代
- 幸存区(from、to)
- 老年区
GC按照回收的区域又分两种类型:
- 普通的GC(minor GC)— 轻GC
- 只针对新生代区域的GC
- 全局GC (major GC or Full GC)— 重GC
- 针对老年代的GC,偶尔伴随对新生代的GC以及对永久代的GC
GC面试题
1、JVM内存模型及分区,需要详细到每个区放什么?
- JVM内存模型
- JVM分区为:
- 方法区
- 堆区
- 栈区
- 程序(PC)计数器
- 本地方法栈
- 每个区存放:
2、堆里面的分区及各自的特点。
- Eden区-伊甸区
- Eden区位于Java堆的年轻代,是新对象分配内存的地方。由于堆是所有线程共享的,因此在堆上分配内存需要加锁。
- 而Sun JDK为提升效率,会为每个新建的线程在Eden上分配一块独立的空间由该线程独享,这块空间称为TLAB(Thread Local Allocation Buffer)。
- 在TLAB上分配内存不需要加锁,因此JVM在给线程中的对象分配内存时会尽量在TLAB上分配。如果对象过大或TLAB用完,则仍然在堆上进行分配。如果Eden区内存也用完了,则会进行一次Minor GC(轻GC)。
- Survival from to-幸存区
- Survival区与Eden区都在Java堆的年轻代。Survival区有两块(from区、to区),这两个区是相对的,再发生一次Minor GC后,from区和to区就会发生互换。
- 在发生Minor GC时,Eden区和Survival from区会把一些仍然存活的对象复制进Survival to区,并清除内存,Survival to区会把一些存活得足够久的对象移至年老代。
- 老年代
- 老年代里存放的都是存活时间较久的、大小较大的对象,因此年老代使用标记整理算法。
- 当老年代容量满时,会触发一次重GC(full GC),回收老年代和年轻代中不再被使用的对象资源。
3、GC垃圾回收三种收集方法的原理与特点,分别用在什么地方?
- Java内存管理主要涉及三个部分:
- 堆(Java代码可及的Java堆和JVM自身使用的方法区)
- 栈(服务Java方法的虚拟机栈和服务Native方法的本地方法栈)
- 保证程序在多线程环境下能够连续执行的程序计数器
- Java堆是进行垃圾回收的主要区域,故其也称为GC堆;而方法区的垃圾回收主要针对的是新生代和幸存区。总的来说,堆(包括Java堆和方法区)是垃圾回收的主要对象,特别是Java堆。
判断对象是否需要回收(对象存活的判断)有两种算法:
- 引用计数
- 每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。
- 此方法虽然简单,但无法解决对象相互循环引用的问题。
- 可达性分析
- 从GC Roots开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
- 在Java中,GC Roots包括:
- 虚拟机栈中引用的对象
- 方法区中类静态属性实体引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
垃圾回收有三种算法:
- 标记清除算法
- 第一步:标记出所有需要被回收的对象
- 第二步:对标记的对象进行统一清除,清空对象所占的内存区域
- 缺点
- 执行效率不可控
- 产生了许多内存碎片
- 标记复制算法
- 是针对标记清除算法执行效率与内存碎片的缺点,提出的一种改进算法。
- 就是将内存分为大小相同的两个区域(运行区域和预留区域),所有创建的新对象都分配到运行区域,当运行区域不够时,将运行区域中存活对象全部复制到预留区域,再清空整个运行区域内存。
- 能够很好地兼顾执行效率与内存碎片的问题,但存在缺点:预留一半的内存区域未免有些浪费了。
- 标记压缩算法
- 标记压缩算法的标记阶段与其他算法一样,但在压缩阶段,算法是将存活的对象向内存空间一端移动,然后将存活对象边界以外的空间全部清空。
- 缺点:比如当垃圾对象少的时候,要移动大量的存活对象才能获取少量的内存空间。
总之,不同的垃圾回收算法都有各自的优缺点。
4、Minor GC(轻GC) 与 Full GC(重GC) 分别在什么时候发生?
- 如果 Eden 空间占满了, 会触发 minor GC。Minor GC 后仍然存活的对象会被复制到 S0 (survivor 0)中去,这样 Eden 就被清空可以分配给新的对象。
- 又触发了一次 Minor GC ,
- S0 和 Eden 中存活的对象被复制到 S1 中,并且 S0和 Eden 被清空。
- 在同一时刻, 只有 Eden 和一个 Survivor 区同时被操作。
- 当每次对象从 Eden 区复制到 Survivor区或者从 Survivor区中的一个复制到另一个,计数器会自动增加值。默认情况下如果复制发生超过 16次,JVM 会停止复制并把他们移到老年代中去。
- 同样如果一个对象不能在 Eden 区中被创建,它会直接被创建在老年代中。如果老年代的空间被占满会触发老年代的 GC, 也被称为 Full GC。
- Full GC 是一个压缩处理过程, 比 Minor GC 要慢很多。
综上,FULL GC (重GC)发生的原因有两种:
- 大对象分配时引发老年代空间不足;
- 持续存活的对象转移到老年代引发的空间不足。
有如下原因可能导致Full GC:
- a) 年老代(Tenured)被写满;
- b) 持久代(Perm)被写满;
- c) System.gc()被显示调用;
-
GC四大算法
引用计数法(了解)
每个对象有一个引用计数器,当对象被引用一次则计数器加1,当对象引用失效一次,则计数器减1,对于计数器为0的对象意味着是垃圾对象,可以被GC回收。
缺点: 每次对象赋值时均要维护引用计数器,且计数器本身也有一定的消耗;
-
可达性算法
目前虚拟机基本都采用可达性算法,从GC Roots 作为起点开始搜索,那么整个连通图中的对象边都是活对象,对于GC Roots 无法到达的对象变成了垃圾回收对象,随时可被GC回收。
在Java中,GC Roots包括:
- 虚拟机栈中引用的对象
- 方法区中类静态属性实体引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
复制算法(Copying)
年轻代中使用的是Minor GC(轻GC),采用的就是复制算法(Copying)
什么是复制算法?
Minor GC(轻GC) 会把Eden区中的所有活对象都移到Survivor区中,如果Survivor区放不下,那剩下的活对象就被移动到Old generation(老年区)中。也就是说,一旦收集后,Eden就是变成空的了。
当对象在Eden区(包括一个Survivor区,这里假设是From区)出生后,在经过一次Minor GC后,如果对象还存活,且能够被另一块Survivor区所容纳 (这里应为to区,即to区有足够的内存空间来存储Eden区和From区中存活的对象),则使用复制算法将这些仍然活着的对象复制到另一块Survivor区(即 to 区)中,然后清理所使用过的Eden区及Survivor区域(即form区),并且将这些对象的年龄设置为1,以后对象在Survivor区,每熬过一次Minor GC,就将这个对象的年龄 +1。当这个对象的年龄达到某一个值的时(默认是15岁,通过 -XX:MaxTenuringThreshold 设定参数)这些对象就会成为老年代。
-XX:MaxTenuringThreshold 任期门槛 => 设置对象在新生代中存活的次数
面试题:如何判断哪个是to区呢?一句话:谁空谁是to
原理解释:
HotSpot JVM 把年轻代分为三部分:一个 Eden区和2个Survivor区(from区和to区)。
默认比例为 8:1:1,一般情况下,新创建的对象会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor中每熬过一次Minor GC ,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。因为年轻代中的对象基本上都是朝生夕死,所以在年轻代的垃圾回收算法使用的是复制算法!
复制算法的思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上。复制算法不会产生内存碎片!
在GC开始的时候,对象只会在Eden区和名为 From 的Survivor区,Survivor区“TO” 是空的,紧接着进行GC,Eden区中所有存活的对象都会被复制到 “To” , 而在 “From” 区中,仍存活的对象会根据他们的年龄值来决定去向。
年龄达到一定值的对象会被移动到老年代中,没有达到阈值的对象会被复制到 “To区域”,经过这次GC后,Eden区和From区已经被清空,这时 “From” 和 “To” 会交换他们的角色。
不管怎样,都会保证名为To 的Survicor区域是空的(谁空谁是To)。 Minor GC会一直重复这样的过程。直到 To 区 被填满 , ”To “ 区被填满之后,会将所有的对象移动到老年代中。
因为Eden区对象一般存活率较低,一般地,使用两块10%的内存作为空闲和活动区域,而另外80%的内存,则是用来给新建对象分配内存的。一旦发生GC,将10%的from活动区间与另外80%中存活的Eden对象转移到10%的to空闲区域,接下来,将之前的90%的内存,全部释放,以此类推;
好处:没有内存碎片。
坏处:浪费内存空间。
- 浪费了一半的内存
- 如果对象的存活率很高,可以极端一点,假设100%存活,那么需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。
- 所以从以上描述不难看出:复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,必须要克服50%的内存浪费。
标记清除(Mark-Sweep)
说明:老年代一般是由标记清除或者是标记清除与标记压缩的混合实现
标记清除:回收时,对需要存活的对象进行标记;回收不是绿色的对象。
当堆中有效内存空间被耗尽时,就会停止整个程序,然后进行两项工作——标记和清除。
- 标记:从引用根节点开始标记所有被引用的对象,标记的过程就是遍历所有的GC Roots ,然后将所有GC Roots 可达的对象,标记为存活的对象。
- 清除: 遍历整个堆,把未标记的对象清除。
优点:不需要额外的空间。
缺点:这个算法需要暂停整个应用,两次扫描严重浪费时间,会产生内存碎片。
- 用通俗的话解释一下: 标记/清除算法,就是当程序运行期间,若可以使用的内存被耗尽时,GC线程会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行。
劣势:
- 效率比较低(递归与全堆对象遍历),且在进行GC时需停止应用程序,会导致用户体验非常差劲。
主要的缺点则是这种方式清理出来的空闲内存是不连续的。因为死亡对象都是随机出现在内存的各个角落,把他们清除之后,内存的布局自然乱七八糟,而为了应付这一点,JVM就不得不维持一个内存空间的空闲列表,这又是一种开销;而且在分配数组对象的时候,寻找连续的内存空间会不太好找。
标记压缩(Mark-Compact)
说明:老年代一般是由标记清除或者是标记清除与标记压缩的混合实现。
什么是标记压缩?
在整理压缩阶段,不再对标记的对象作回收,而是通过所有存活对象都向一端移动,然后直接清除边界以外的内存。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
标记压缩算法不仅可以弥补标记清除算法当中内存区域分散的缺点,也消除了复制算法中内存减半的高额代价。标记清除压缩(Mark-Sweep-Compact)
小总结内存效率(时间复杂度):复制算法 > 标记清除算法 > 标记压缩算法
- 内存整齐度:复制算法 = 标记压缩算法 > 标记清除算法
- 内存利用率:标记压缩算法 = 标记清除算法 > 复制算法
没有最好的算法,只有最合适的算法 。 —> GC:分代收集算法
年轻代:(Young Gen)
- 年轻代特点是区域相对老年代较小,对象存活率低。
- 这种情况利用复制算法的速度是最快的。复制算法的效率只和当前存活对象大小有关,因而很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。
老年代:(Tenure Gen)
- 老年代的特点是区域较大,对象存活率高。
- 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记清除或者是标记清除与标记压缩的混合实现。
- 标记阶段的开销与存活对象的数量成正比,对于老年代,标记清除或者标记压缩有一些不符,但可以通过多核、多线程、并发、并行的形式提高标记效率。
清除阶段的开销与所管理区域的大小相关,但回收的过程没有对象的移动。使其相对其他有对象移动步骤的回收算法,仍然是是效率最好的,但是需要解决内存碎片的问题。
JMM
Java内存模型(Java Memory Model)— JMM
为什么要有内存模型
CPU和缓存一致性
计算机在执行程序的时候,每条指令都是在CPU中执行的,而执行的时候,又免不了要和数据打交道。而计算机上的数据,是存放在主存当中的,也就是计算机的物理内存。但随着CPU技术的发展,CPU的执行速度越来越快。而由于内存的技术并没有太大的变化,所以从内存中读取和写入数据的过程和CPU的执行速度比起来差距就会越来越大,这就导致CPU每次操作内存都要耗费很多等待时间。
所以,人们想出来了一个好的办法,就是在CPU和内存之间增加高速缓存。缓存的概念,就是保存一份数据拷贝。他的特点是速度快,内存小,并且昂贵。
那么,程序的执行过程就变成了:
当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。
而随着CPU能力的不断提升,一层缓存就慢慢的无法满足要求了,就逐渐的衍生出多级缓存。按照数据读取顺序和与CPU结合的紧密程度,CPU缓存可以分为一级缓存(L1)、二级缓存(L2),部分高端CPU还具有三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。
那么,在有了多级缓存之后,程序的执行就变成了:
当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。
单核CPU只含有一套L1、L2、L3缓存;如果CPU含多个核心(即多核CPU),则每个核心都含有一套L1(甚至和L2)缓存,而共享L3(或者和L2)缓存。
下图为一个单CPU双核的缓存结构。
随着计算机能力不断提升,开始支持多线程。我们分别来分析下单线程、多线程在单核CPU、多核CPU中的影响。单线程:cpu核心的缓存只被一个线程访问。缓存独占,不会出现访问冲突等问题。
- 单核CPU,多线程:进程中的多个线程会同时访问进程中的共享数据,CPU将某块内存加载到缓存后,不同线程在访问相同的物理地址时,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效。但由于任何时刻只能有一个线程在执行,因此不会出现缓存访问冲突。
- 多核CPU,多线程:每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的缓冲中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自缓存的情况,而各自的欢唱之间的数据就有可能不同。
在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核自己的缓存中,关于同一个数据的缓存内容可能不一致。
处理器优化和指令重排
上面提到在CPU和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。除了这种情况,还有一种硬件问题也比较重要:为了使处理器内部的运算单元能够尽量被充分利用,处理器可能会对输入代码进行乱序执行处理,这就是处理器优化。
除了现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做指令重排。
可想而知,如果任由处理器优化和编译器对指令重排的话,就可能导致各种各样的问题。
并发编程的问题
关于并发编程的问题,比如原子性问题、可见性问题和有序性问题。
其实,原子性问题、可见性问题和有序性问题,是人们抽象定义出来的,而这个抽象的底层问题就是前面提到的缓存一致性问题、处理器优化问题和指令重排问题等。
并发编程,为了保证数据的安全,需要满足以下三个特性:
- 原子性:是指在一个操作中cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。
- 可见性:是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
- 有序性:即程序执行的顺序按照代码的先后顺序执行。
缓存一致性问题其实就是可见性问题,而处理器优化是可以导致原子性问题的,指令重排即会导致有序性问题。
什么是内存模型
缓存一致性问题、处理器优化的指令重排问题是硬件的不断升级导致的。
为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。
- 通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。
- 它与处理器有关、与缓存有关、与并发有关、与编译器也有关。
- 它解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。
内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。
什么是Java内存模型
计算机内存模型是解决多线程场景下并发问题的一个重要规范。不同编程语言,在实现上可能有所不同。
Java程序需要运行在Java虚拟机上,Java内存模型(Java Memory Model,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
- Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。
- 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。
所以,JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。
Java内存模型的实现
在Java中提供了一系列和并发处理相关的关键字,比如volatile、synchronized、final、concurren包等。其实这些就是Java内存模型封装了底层的实现后提供给程序员使用的一些关键字。
在开发多线程的代码的时候,我们可以直接使用synchronized等关键字来控制并发,从来就不需要关心底层的编译器优化、缓存一致性等问题。所以,Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。
原子性
在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit。在synchronized的实现原理文章中介绍过,这两个字节码,在Java中对应的关键字就是synchronized
。
因此,在Java中可以使用**synchronized**
关键字来保证方法和代码块内的操作是原子性的。
可见性
Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。
Java中的volatile
关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次用之前都从主内存刷新。
因此,可以使用volatile来保证多线程操作时变量的可见性。
除了**volatile**
,Java中的**synchronized**
和**final**
两个关键字也可以实现可见性。
有序性
在Java中,可以使用**synchronized**
和**volatile**
来保证多线程之间操作的有序性。
实现方式有所区别:
- volatile关键字会禁止指令重排。
- synchronized关键字保证同一时刻只允许一 条线程操作。
synchronized关键字可以同时满足以上三种特性,但synchronized是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。
总结
可以参考《深入理解Java虚拟机》和《Java并发编程的艺术》两本书。
常见面试题
1、JVM 垃圾回收的时候如何确定垃圾?什么是 GC Roots?
垃圾:简单的说就是内存中已经不再被使用到的空间就是垃圾。
Person person = null;
要进行垃圾回收,如何判断一个对象是否可以被回收?
- 方法一:引用计数法(了解即可)
Java中,引用和对象是有关联的,如果要操作对象则必须用引用进行。
因此,一个简单的办法是通过引用计数来判断一个对象是否可以进行回收。简单说,给对象中添加一个引用计数器,每当有一个地方引用它,计数器值加1,每当有一个引用失效时,计数器减1。任何时刻计数器值为0的对象就是不可能再被使用的,那么这个对象就是可回收对象。
为什么主流 Java虚拟机没有选用这种算法?
—— 最主要的原因是它很难解决对象之间相互循环引用的问题。
- 方法二:可达性分析算法
为了解决引用计数法的循环引用问题,Java 使用了可达性分析的方法。
所谓 GC roots 或者说 tracing GC 的 “根集合” 就是一组必须活跃的引用。
基本思路:通过一系列名为 GC Roots 的对象作为起始点,从这个被称为 GC Roots的对象开始向下搜索,如果一个对象到 GC Roots 没有任何引用链相连时,则说明此对象不可用。也即给定一个集合的引用作为根出发,通过引用关系遍历对象图,能被遍历到的(可到达的)对象就被判定为存活,没有被遍历到的就自然被判定为死亡。
Java中可作为 GC Roots的对象:(共4种)
- 虚拟机栈(栈帧中的局部变量表)中引用的对象;
- 方法区中的类静态属性引用的对象。
- 方法区中常量引用的对象。
本地方法栈中 JNI (Native方法)引用的对象。 ```java public class GCrootDemo{ private byte[] byteArray = new byte[10010241024];
//private static GCrootDemo2 t2; //private static final GCrootDemo3 t3 = new GCrootDemo3();
public static void m1(){ GCrootDemo t1 = new GCrootDemo(); System.gc(); System.out.println(“第一次GC完成”); }
public static void main(String[] args){ m1(); }
}
<a name="LVUSA"></a>
### 2、JVM 调优和参数配置,如何盘点查看 JVM 系统默认值。
**JVM的参数类型有三种:标配参数、X参数、XX参数**
- **标配参数**:在 JDK 各个版本之间很稳定,很少有大的变化;
```bash
-version
-help
-showversion
X参数(了解)
-Xint # 解释执行
-Xcomp # 第一次使用就编译成本地代码
-Xmixed # 混合模式
XX参数之Boolean类型
- 公式: -XX: + 或者 - 某个属性值
- + 表示开启,- 表示关闭。
- 公式: -XX: + 或者 - 某个属性值
启动代码测试:
package com.kuang.gc;
public class GCDemo01 {
public static void main(String[] args) throws InterruptedException {
System.out.println("Hello");
Thread.sleep(Integer.MAX_VALUE);
}
}
如何查看一个正在运行中的 Java程序,它的某个 JVM 参数是否开启?具体值是多少?
- jps -l :得到当前正在运行的进程编号
- jinfo -flag 进程号: 查看正则运行中的 Java程序,它的某个 JVM 参数是否开启
停止程序,然后增加 VM 参数:
-XX:+PrintGCDetails
启动后再次测试,看是否开启!
小结:
1、是否打印GC收集细节:PrintGCDetails
2、是否使用串行垃圾回收器:UseSerialGC
- XX参数之KV设值类型
- 公式: -XX: 属性key = 属性值value
1、-XX:MetaspaceSize=128m
元空间大小
2、-XX:MaxTenuringThreshold=15
进老年区存活次数判定
美团面试题:两个经典参数: -Xms 和 -Xmx ,请问这个怎么解释呢?
1、-Xms 等价于 -XX:InitialHeapSize 初始堆大小
2、-Xmx 等价于 -XX:MaxHeapSize 最大堆大小
查看初始默认值
-XX:+PrintFlagsInitial:查看Java环境初始默认值
java -XX:+PrintFlagsInitial
-XX:+PrintFlagsFinal:查看修改更新
java -XX:+PrintFlagsFinal -version
// 具体执行 后面是要修改的参数, Test要运行的 Java 类
java -XX:+PrintFlagsFinal -Xss128K Test
java -XX:+PrintCommandLineFlags -version 程序运行前打印出用户手动设置或JVM自动设置的XX选项
3、平时工作用过的 JVM 常用基本配置参数有哪些?
- -Xms:初始内存大小,默认为物理内存的 1/64,等价于 -XX:InitialHeapSize。
- -Xmx:最大内存大小,默认为物理内存的1/4,等价于 -XX:MaxHeapSize。
- -Xss:设置单个线程栈的大小,一般默认为 512k ~ 1024k,等价于 -XX:TheadStackSize。
- -Xmn:设置年轻代大小,一般不用动。
- -XX:MetaspaceSize:设置元空间大小
- 元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
- 因此,默认情况下,元空间的大小仅受本地内存限制。使用: -XX:MetaspaceSize=512m
- -XX:+PrintGCDetails:输出详细GC收集日志信息;输出参数说明如下:
- -XX:SurvivorRatio:设置新生代中 eden 和 s0/s1 空间的比例。
- 默认 -XX:SurvivorRatio=8,Eden:S0:S1=8:1:1
- 假如 -XX:SurvivorRatio=4,Eden:S0:S1=4:1:1
- SurvivorRatio 值就是设置 eden区的比例占多少,s0/s1 相同
- -XX:NewRatio:配置年轻代与老年代在堆结构的占比。
- 默认 -XX:NewRatio=2 新生代占1,老年代2,年轻代占整个堆的 1/3
- 假如 -XX:NewRatio=4 新生代占1,老年代4,年轻代占整个堆的 1/5
- NewRatio 值是老年代的占比,剩下的1给新生代。
- -XX:MaxTenuringThreshold:设置进入老年区的年龄限制,必须在 0~15 之间,默认 15。
4、强引用、软引用、弱引用、虚引用分别是什么?
整体架构
强引用(默认支持模式)
当内存不足,JVM开始垃圾回收,对于强引用的对象,即使出现了OOM也不会对该对象进行回收,死都不收。
强引用是我们最常见的普通引用,只要还有强引用指向一个对象,就能表明对象还活着,垃圾收集器不会碰这种对象。在 Java中最常见的就是强引用,把一个对象赋值给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用时,它处在可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到,JVM也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之一。
对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或显式地将相应(强)引用赋值为null,一般认为是可以被垃圾收集的了(当然具体回收时机还是要看垃圾收集策略)。
Object obj1 = new Object(); // 这样定义的默认就是强引用
Object obj2 = obj1; // obj2引用赋值
obj1 = null; // 置空
System.gc();
System.out.println(obj2); // 正常输出
软引用(SoftReference)
软引用是一种相对强引用弱化了一些的引用,需要调用 java.lang.ref.SoftReference类来实现,可以让对象豁免一些垃圾收集。
对于只有软引用的对象来说:当系统内存充足时它不会被回收,当系统内存不足时它会被回收。
软引用通常在对应内存敏感的程序中,比如高速缓存就用到软引用,内存够用时就保留,不够用就回收!
package com.kuang.gc;
import java.lang.ref.SoftReference;
public class SoftReferenceDemo {
public static void softRef_Memory_Enough(){
Object o1 = new Object();
SoftReference<Object> softReference = new SoftReference<>(o1);
System.out.println(o1);
System.out.println(softReference.get());
o1 = null;
System.gc();
System.out.println(o1);
System.out.println(softReference.get());
}
/*
JVM 配置,让它内存不够
-Xms5m -Xmx5m -XX:+PrintGCDetails
*/
public static void softRef_Memory_NotEnough(){
Object o1 = new Object();
SoftReference<Object> softReference = new SoftReference<>(o1);
System.out.println(o1);
System.out.println(softReference.get());
o1 = null;
//System.gc();
try {
byte[] bytes = new byte[30*1024*1024];
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println(o1);
System.out.println(softReference.get());
}
}
public static void main(String[] args) {
softRef_Memory_NotEnough();
}
}
弱引用 WeakReference
弱引用需要用 java.lang.ref.WeakReference 类来实现,它比软引用的生存周期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内容空间是否足够,都会回收该对象占用的内存。
public static void main(String[] args) {
Object o1 = new Object();
WeakReference<Object> weakReference = new WeakReference<>(o1);
System.out.println(o1);
System.out.println(weakReference.get());
o1 = null;
System.gc();
System.out.println(o1);
System.out.println(weakReference.get());
}
软引用和弱引用的适用场景
假如有一个应用需要读取大量的本地图片:
- 如果每次读取图片都要从硬盘读取则会严重影响性能;
- 如果一次性全部加载到内存中又可能造成内存溢出。
此时适用软引用可以解决这类问题:
设计思路是:用一个HashMap来保存图片的路径和相应图片对象关联的软引用之间的映射关系,在内存不足时,JVM 会自动回收这些缓存图片对象所占用的空间,从而有效地避免了 OOM 的问题。
Map<String,SoftReference<Bitmap>> imageCache
= new HashMap<String,SoftReference<Bitmap>>();
虚引用 PhantomReference
虚引用需要使用 java.lang.PhantomReference 类来实现。
顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。
如果一个对象仅持有虚引用,那它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收,它不能单独使用也不能通过它访问对象,虚引用必须和引用队列(ReferenceQueue)联合使用。
虚引用的主要作用是跟踪对象被垃圾回收的状态。仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制。虚引用的get方法总是返回 null,因此无法访问对应的引用对象。其意义在于说明一个对象已经进入 finalization 阶段,可以被 gc 回收,用来实现比 finalization 机制更灵活的回收操作。
换句话说,设置虚引用关联的唯一目的,就是在这个对象被收集器回收的时候收到一个系统通知或者后续添加进一步的处理。
Java技术允许使用 finalize() 方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。
public static void main(String[] args) throws InterruptedException {
Object o1 = new Object();
ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
PhantomReference<Object> phantomReference = new PhantomReference<>
(o1,referenceQueue);
System.out.println(o1); // java.lang.Object@1b6d3586
System.out.println(phantomReference.get()); // null
System.out.println(referenceQueue.poll()); // null
System.out.println("===================");
o1 = null;
System.gc();
TimeUnit.SECONDS.sleep(1);
System.out.println(o1); // null
System.out.println(phantomReference.get()); // null
System.out.println(referenceQueue.poll()); // java.lang.Object@1b6d3586
}
Java提供了4中引用类型,在垃圾回收的时候,都有自己各自的特点。
引用队列(ReferenceQueue)是用来配合引用工作的,没有 ReferenceQueue一样可以运行。
创建引用的时候可以指定关联的队列,当GC 释放对象内存的时候,会将引用加入到引用队列,如果程序发现某个虚引用已经被加入到引用队列,那就可以在所引用对象的内存被回收之前采取必要的行动。这相当于是一种通知机制。
当关联的引用队列中有数据的时候,意味着引用指向的堆内存中的对象被回收。通过这种方式,JVM 允许我们在对象被销毁后,做一些我们自己想做的事情。
一幅图小总结
5、请你谈谈你对 OOM 的认识?
java.lang.StackOverflowError
public static void main(String[] args){
a();
}
public static void a(){
a();
}
java.lang.OutOfMemoryError:Java heap space
// -Xms10m -Xmx10m
public static void main(String[] args){
String str = "kuangshen";
while(true){
str += str + new Random().nextInt(11111111) + new
Random().nextInt(11111111);
}
}
java.lang.OutOfMemoryError:GC overhead limit exceeded:超过GC开销限制
GC回收时间过长时会抛出 OutOfMemoryError。
过长的定义是:超过98%的时间用来做GC并且回收了不到 2% 的堆内存,连续多次 GC 都只回收了不到 2% 的极端情况下才会抛出。
假如不抛出 GC overhead limit exceeded 错误会发生什么情况呢?那就是 GC 清理的这么点内存很快会再次填满,迫使GC再次执行,这样就形成恶性循环,CPU使用率一直是100%,而GC却没有任何成果。
// -Xms10m -Xmx10m -XX:MaxDirectMemorySize=5m -XX:+PrintGCDetails
public static void main(String[] args) throws InterruptedException {
int i = 0;
List<String> list = new ArrayList<>();
try {
while (true){
list.add(String.valueOf(++i).intern());
}
} catch (Throwable e) {
System.out.println("i=>"+i);
e.printStackTrace();
throw e;
}
// java.lang.OutOfMemoryError: GC overhead limit exceeded
}
- java.lang.OutOfMemoryError:Direct buffer memory:直接缓冲存储
写NIO程序经常使用 ByteBuffer 来读取或者写入数据,这是一种基于通道(channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
ByteBuffer.allocate(capability) 第一种方式是分配JVM堆内存,属于GC管辖范围,由于需要拷贝所以速度相对较慢。
ByteBuffer.allocateDirect(capability) 第二种方式是分配 OS本地内存,不属于GC管辖范围,由于不需要拷贝所以速度相对较快。
但如果不断分配本地内存,堆内存很少使用,那么JVM就不需要执行 GC,DirectByteBuffer 对象们就不会被回收,这时候堆内存充足,但本地内存可能已经使用光了,再次尝试分配本地内存就会出现OutOfMemoryError,那程序就直接崩溃了。
// -Xms10m -Xmx10m -XX:MaxDirectMemorySize=5m -XX:+PrintGCDetails
public static void main(String[] args) throws InterruptedException {
System.out.println("配置的maxDirectMemory:"+
(sun.misc.VM.maxDirectMemory()/(double)1014/1024)+"MB");
TimeUnit.SECONDS.sleep(2);
// -Xms10m -Xmx10m -XX:MaxDirectMemorySize=5m -XX:+PrintGCDetails
// 我们配置的5M,但是实际使用的6M,故意搞破坏
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(6 * 1024 * 1024);
// java.lang.OutOfMemoryError: Direct buffer memory
}
- java.lang.OutOfMemoryError:unable to create new native thread:无法创建新的本地线程
高并发请求服务器时,经常出现如下异常:java.lang.OutOfMemoryError:unable to create new native thread
。准确地讲,该native thread 异常与对应的平台有关。
导致原因:
1、应用创建了太多线程,一个应用进程创建多个线程,超过系统承载极限。
2、服务器并不允许你的应用程序创建这么多线程,Linux 系统默认允许单个进程可以创建的线程数是1024个,你的应用创建超过这个数量,就会报 java.lang.OutOfMemoryError:unable to create new native thread
解决办法:
1、想办法降低你的应用程序创建线程的数量,分析应用是否真的需要创建这么多线程,如果不是,改代码将线程数降到最低。
2、对于有的应用,确实需要创建很多线程,远超过Linux系统的默认 1024 个线程的限制,可以通过修改 Linux 服务器配置,扩大Linux默认限制!
//在 Linux 虚拟机下操作,使用非 root 用户测试,因为root用户是无上限创建线程的。
public static void main(String[] args) {
for (int i = 1; ; i++) {
System.out.println("i=>"+i);
new Thread(()->{
try {
Thread.sleep(Integer.MAX_VALUE);
} catch (InterruptedException e) {
e.printStackTrace();
}
},""+i).start();
}
}
- java.lang.OutOfMemoryError:Metaspace
Java 8 及之后的版本使用 Metaspcae 来替代永久代。Metaspace 是方法区在 HotSpot中的实现,它与持久代最大的区别在于:Metaspace并不在虚拟机内存中而是使用本地内存。
永久代(java8后被元空间Metaspace取代)存放了以下信息:
- 虚拟机加载的类信息
- 常量池
- 静态变量
- 即时编译后的代码
模拟Metaspace空间溢出,我们不断生成类往元空间灌,类占据的空间总是会超过Metaspace指定的空间大小的。
import org.springframework.cglib.proxy.Enhancer;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
// 注意要导入 spring 的核心包!
// -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
public class Test{
static class OOMTest{ }
public static void main(final String[] args) {
int i = 0; //模拟计数器,来查看多少次以后发生异常
try{
while(true){
i++;
// Spring的cglib动态代理技术
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMTest.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
public Object intercept(Object o, Method method,
Object[] objects, MethodProxy methodProxy) throws Throwable {
return method.invoke(o,args);
}
});
enhancer.create();
}
}catch(Throwable e){
System.out.println("i=>"+i);
e.printStackTrace();
}
}
}
6、GC垃圾回收算法和垃圾收集器的关系?分别是什么?
GC算法(引用计数、复制、标记清除、标记压缩)是内存回收的方法论,垃圾收集器是算法的落地实现。
因为目前为止还没有完美的收集器出现,更加没有万能的收集器,只是针对具体应用最合适的收集器,进行分代收集。
4 种主要垃圾收集器
- 串行垃圾回收器(Serial)
它为单线程环境设计且只使用一个线程进行垃圾回收,会暂停所有的用户线程。所以不适合服务器环境。
- 并行垃圾回收器(Parallel)
多个垃圾收集线程并行工作,此时用户线程是暂停的,适用于科学计算、大数据处理首台处理等弱交互场景。
- 并发垃圾回收器(CMS)
用户线程和垃圾收集线程同时执行(不一定是并行,可能交替执行),不需要停顿用户线程,互联网公司多用它,适用对响应时间有要求的场景。
- G1垃圾回收器
G1垃圾回收器将堆内存分割成不同的区域然后并发的对其进行垃圾回收
7、谈谈垃圾收集器。
怎么查看默认的垃圾收集器是哪个?
命令行输入:
java -XX:+PrintCommandLineFlags -version
默认的垃圾收集器有哪些?
java 的 gc 回收的类型主要有几种:
- UseSerialGC
- UseParallelGC
- UseConcMarkSweepGC
- UseParNewGC
- UseParallelOldGC
- UseG1GC
垃圾收集器
垃圾收集器用来具体实现这些GC算法并实现内存回收。不同厂商、不同版本的虚拟机实现差别很大,HotSpot 中包含的收集器如下图所示:
部分参数说明
- DefNew => Default New Generation 【默认新一代】
- Tenured => Old 【老年代】
- ParNew => Parallel New Generation 【并行新一代】
- PSYoungGen => Parallel Scavenge 【并行清除年轻区】
- ParOldGen => Parallel Old Generation 【并行老年区】
Server / Client 模式分别是什么意思?
适用范围:只需要掌握 Server 模式即可,Client模式基本不会用
操作系统:
- 32位Window操作系统,不论硬件如何都默认使用 Client 的 JVM 模式。
- 32位其他操作系统,2G 内存同时有 2个cpu以上用Server模式,低于该配置还是 Client 模式。
- 64位都是 Server 模式。
新生代
- 串行GC(Serial收集器)/ (Serial Copying)
一句话:一个单线程的收集器,在进行垃圾收集时,必须暂停其他所有的工作线程直到它收集结束。
串行收集器是最古老、最稳定以及效率高的收集器,只使用一个线程去回收但其在进行垃圾收集过程中可能会产生较长的停顿,Stop-The-World。虽然在收集垃圾过程中需要暂停所有其他的工作线程,但它简单高效,对于限定单个 CPU 环境来说,没有线程交互的开销可以获得最高的单线程垃圾收集效率,因此Serial垃圾收集器依然是java虚拟机运行在 Client 模式下默认的新生代垃圾收集器。
对应 JVM 参数是: -XX:+UseSerialGC
开启后会使用:Serial(Young区用)+ Tenured(Old区用)的收集器组合
表示:新生代、老年代都会使用串行回收收集器,新生代使用复制算法,老年代使用标记-压缩算法;
-Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+PrintCommandLineFlags -XX:+UseSerialGC
- 并行GC(ParNew)
一句话:使用多线程进行垃圾回收,在垃圾收集时,会 Stop-The-World 暂停其他所有的工作线程直到它收集结束。
ParNew 收集器就是 Serial 收集器新生代的并行多线程版本,最常见的应用场景是配合老年代的CMG GC 工作,其余的行为和Serial 收集器完全一样。ParNew 垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程,是很多 java 虚拟机运行在Server模式下新生代的默认垃圾收集器。
常用JVM 参数 :-XX:+UseParNewGC:启用 ParNew 收集器,只影响新生代的收集,不影响老年代。
开启上述参数后,会使用:ParNew(Young区用)+ Tenured 的收集器组合,新生代使用复制算法,老年代采用标记-压缩算法。
但是 ParNew + Tenured 这样的搭配, Java8已经不再被推荐;
说明:
-XX:ParallelGCThreads=N 限制线程数量,默认开启和CPU数目相同的线程数。也可以通过N开启自定义的线程数
cpu>8 N = 5/8
cpu<8 N = 实际个数
-Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+UseParNewGC
警告:
Java HotSpot(TM) 64-Bit Server VM warning: Using the ParNew young collector with the Serial old collector is deprecated and will likely be removed in a future release
- 并行回收GC(Parallel)/(Parallel Scavenge)
Parallel Scavenge 收集器类似 ParNew ,也是一个新生代垃圾收集器,使用复制算法,也是一个并行的多线程的垃圾收集器,俗称吞吐量优先收集器。一句话:串行收集器在新生代和老年代的并行化。
它重点关注的是:
- 可控制的吞吐量。比如程序运行100分钟,垃圾收集时间1分钟,吞吐量就是 99%。高吞吐量意味着高效利用CPU的时间,它多用于在后台运算而不需要太多交互的任务。
- 自适应调节策略也是 ParallelScavenge 收集器与 ParNew 收集器的一个重要区别。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间(-XX:MaxGCPauseMillis)或最大的吞吐量。
常用JVM 参数: -XX:+UseParallelGC 或 -XX:+UseParallelOldGC :(可互相激活)使用ParallelScavenge 收集器。
开启该参数后:新生代使用复制算法,老年代使用标记-整理算法。
老年代
- 串行GC(Serial Old)/ (Serial MSC)
Serial Old 是 Serial 垃圾收集器老年代版本,它同样是单个单线程的收集器,使用标记-压缩算法,主要运行在Client默认的 java虚拟机默认的年老代垃圾收集器。
在Server模式下,主要有两个用途(了解,版本已经到8及以后):
在 JDK 1.5 之前与新生代的 Parallel Scavenge 收集器搭配使用。(Parallel Scavenge + Serial Old)
作为老年代版本中使用 CMS 收集器的后备垃圾收集方案。
- 并行GC(Parallel Old)/ (Parallel MSC)
Parallel Old 收集器是 Parallel Scavenge 的老年代版本,使用多线程的标记-压缩算法,Parallel Old收集器在 JDK1.6才开始提供。
在 JDK1.6 之前,新生代使用 Parallel Scavenge 收集器只能搭配老年代的 Serial Old 收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量。在 JDK 1.6 之前( Parallel Scavenge + Serial Old)。
Parallel Old 正是为了在年老代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,JDK1.8后可以优先考虑新生代 Parallel Scavenge 和老年代 Parallel Old 收集器的搭配策略。在 JDK 1.8及以后( Parallel Scavenge + Parallel Old)
JVM 常用参数:
-XX:+UseParallelOldGC:使用Parallel Old 收集器,设置该参数后,新生代 Parallel Scavenge + 老年代 Parallel Old
- 并发标记清除GC(CMS)
CMS 收集器(Concurrent Mark Sweep:并发标记清除)是一种以获取最短回收停顿时间为目标的收集器。适合应用在互联网站或 B/S 系统的服务器上,这类应用尤其重视服务器的响应速度,希望系统停顿时间最短。
CMS 非常适合堆内存大、CPU核数多的服务器端应用,也是G1出现之前大型应用的首选收集器。
Concurrent Mark Sweep并发标记清除,并发收集低停顿,并发指的是与用户线程一起执行。
开启该收集器的 JVM 参数: -XX:+UseConcMarkSweepGC:开启该参数后会自动将 -XX:+UseParNewGC 打开;
开启该参数后,使用 ParNew(Young区用)+CMS(Old区用)+ Serial Old 的收集器组合,Serial Old将作为CMS出错的后备收集器。
-Xms10m -Xmx10m -XX:+PrintGCDetails -XX:UseConcMarkSweepGC
优点:并发收集停顿低
缺点:
并发执行,对CPU资源压力大
由于并发进行,CMS在收集与应用线程会同时增加对内存的占用,也就是说,CMS必须要在老年代堆内存用尽之前完成垃圾回收。否则CMS回收失败时,将触发担保机制,串行老年代收集器将会以 STW 的方式进行一次 GC,从而造成较大停顿时间。
采用的标记清除算法会导致大量碎片
标记清除算法无法整理空间碎片,老年代空间会随着应用时长被逐步耗尽,最后将不得不通过担保机制对堆内存进行压缩。CMS也提供了参数 -XX:CMSFullGCsBeForeCompaction(默认0,即每次都进行内存整理) 来指定多少次 CMS 收集之后,进行一次压缩的 Full GC.
GC 之如何选择垃圾收集器
- 单CPU或小内存,单机程序 :-XX:+UseSerialGC
- 多CPU,需要最大吞吐量,如后台计算型应用:-XX:+UseParallelGC 或者 -XX:+UseParakkekOldGC
- 多CPU,追求低停顿时间,需要快速响应如互联网应用:-XX:+UseConcMarkSweepGC 或 -XX:+ParNewGC
8、G1垃圾收集器
以前收集器特点
- 年轻代和老年代是各自独立且连续的内存块;
- 年轻代收集使用单 eden + s0 + s1 进行复制算法;
- 老年代收集必须扫描整个老年代区域;
- 都是以尽可能少而快速地执行 GC 为设计原则。
G1 是什么?
G1(Garbage-First)收集器,是一款面向服务端应用的收集器。
从官网的描述中,我们知道 G1 是一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能地满足垃圾收集暂停时间的要求。
另外,它还具有以下特性:
- 像CMS收集器一样,能与应用程序线程并发执行。
- 整理空闲空间更快。
- 需要更多的时间来预测GC停顿时间。
- 不希望牺牲大量的吞吐性能。
- 不需要更大的 Java Heap。
G1 收集器的设计目标是取代 CMS 收集器,它同 CMS 相比,在以下方面表现的更出色:
- G1 是一个有整理压缩内存过程的垃圾收集器,不会产生很多内存碎片。
- G1 的 Stop The World 更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间。
CMS 垃圾收集器虽然减少了暂停应用程序的运行时间,但存在着垃圾碎片问题。
于是,为了去除内存碎片问题,同时保留 CMS 垃圾收集器低暂停时间的优点,Java7发布了一个新的垃圾收集器-G1垃圾收集器。
G1 是在 2012 年才在JDK1.7u4 中可用,oralce 官方计划在 jdk9 中将G1变成默认的垃圾收集器以替代CMS。它是一款面向服务端应用的收集器,主要应用在多CPU和大内存服务器环境下,极大减少垃圾收集的停顿时间,全面提升服务器的性能,逐步替换 java8以前的 CMS 收集器。
主要改变是 Eden、Survivor 和 Tenured 等内存区域不再是连续的了,而是变成了一个个大小一样的region,每个region从1M到32M不等。一个 region有可能属于Eden、Survivor 或者 Tenured 内存区域。
特点:
- G1能充分利用多CPU、多核环境硬件优势,尽量缩短 STW。
- G1整体上采用标记-压缩算法,局部是通过复制算法,不会产生内存碎片。
- 宏观上G1之中不再区分年轻代和老年代,把内存划分成多个独立的子区域(Region),可以近似理解为一个围棋的棋盘。
- G1收集器里面将整个的内存区都混合在一起了,但其本身依然在小范围内要进行年轻代和老年代的区分,保留了新生代和老年代,但他们不再是物理隔离的,而是一部分Region的集合且不需要 Region是连续的。也就是说依然会采用不同的GC方式来处理不同的区域。
- G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的 survivor(to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换。
底层原理:
Region区域化垃圾收集器:最大好处是化整为零,避免全内存扫描,只需要按照区域来进行扫描即可。
区域化内存划片 Region,整体编为了一系列不连续的内存区域,避免了全内存区的GC操作。
核心思想:将整个堆内存区域分成大小相同的子区域(Region),在 JVM 启动时会自动设置这些子区域的大小,在堆的使用上,G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可。每个分区也不会固定地为某个代服务,可以按需在年轻代和老年代之间切换。启动时可以通过参数 -XX:G1HeapRegionSize=n可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区。大小范围在 1MB ~ 32MB,最多能设置 2048 个区域,也即能够支持的最大内存为:32MB * 2048 = 64G内存!
回收步骤
常用配置参数
开发人员仅仅需要声明以下参数即可:
开启G1=>设置最大内存=>设置最大停顿时间
-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=100
-XX:MaxGCPauseMillis=n 最大GC停顿时间单位毫秒,JVM将尽可能(不保证)停顿小于这个时间。
和 CMS 相比的优势
- G1不会产生内存碎片
- 是可以精确控制停顿,该收集器是把整个堆(新生代、老年代)划分成多个固定大小的区域,每次根据允许停顿的时间去收集垃圾最多的区域。