JAVA-JVM

引言:

Java虚拟机学习

概述

Java文件运行图示:

image-20200408221009731

整个过程可以归结为三个步骤:

  1. Java文件经过编译后变成 .class 字节码文件
  2. 字节码文件通过类加载器被搬运到 JVM 虚拟机中
  3. 虚拟机主要的5大块:方法区,堆都为线程共享区域,有线程安全问题,栈和本地方法栈和计数器都是独享区域,不存在线程安全问题,而 JVM 的调优主要就是围绕堆,栈两大块进行

Java1.8 图示

方法区更改为元空间

image-20200408221348129

另一种图示(差别不大)

下文介绍的点包括:类加载机制;JVM各区(5块);垃圾回收机制

类加载机制

对于程序员编写的HelloWorld.java 文件,JVM 是不认识的,它需要一个 编译 ,让其成为一个JVM可读的二进制文件 HelloWorld.class。当 JVM 想要执行这个 .class 文件,需要将其装进一个 类加载器 中,它就像一个搬运工一样,会把所有的 .class 文件全部搬进JVM中。

image-20200409095917129

JVM各区

方法区

方法区 是用于存放类似于元数据信息方面的数据的,比如类信息,常量,静态变量,编译后代码···等。类加载器将 .class 文件搬过来就是先丢到这一块上

方法区(也称为永久代)是一个 JVM 规范,永久代与元空间都是其一种实现方式。在 JDK 1.8 之后,原来永久代的数据被分到了堆和元空间中。元空间存储类的元信息,静态变量和常量池等放入堆中。元空间位于本地内存中,而不是虚拟机内存中。

主要放了一些存储的数据,比如对象实例,数组···等,它和方法区都同属于 线程共享区域 。也就是说它们都是 线程不安全

这是我们的代码运行空间。我们编写的每一个方法都会放到 里面运行。

对于 本地方法栈 或者 本地方法接口 这两个名词,它俩底层是使用C来进行工作的,和Java没有太大的关系。

程序计数器

主要就是完成一个加载工作,类似于一个指针一样的,指向下一行我们需要执行的代码。和栈一样,都是 线程独享 的,就是说每一个线程都会有自己对应的一块区域而不会存在并发和多线程的问题。

类加载机制详解

类加载机制概念

JVM将Class文件中描述类的数据加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的加载机制

Class文件由类装载器装载后,在JVM中将形成一份描述Class结构的元信息对象,通过该元信息对象可以获知Class的结构信息:如构造函数,属性和方法等,Java允许用户借由这个Class相关的元信息对象间接调用Class对象的功能,这里就是我们经常能见到的Class类。

类加载子系统作用

  • 类加载子系统负责从文件系统或者网络中加载class文件,class文件在文件开头有特定的文件标识(0xCAFEBABE)
  • ClassLoader只负责class文件的加载。至于它是否可以运行,则由Execution Engine决定
  • 加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是class文件中常量池部分的内存映射)
  • Class对象是存放在堆区的

类加载器ClassLoader角色

  1. class file存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM当中来根据这个文件实例化出n个一模一样的实例
  2. class file加载到JVM中,被称为DNA元数据模板,放在方法区
  3. 在.calss文件 -> JVM -> 最终成为元数据模板,此过程就要一个运输工具(类装载器),扮演一个快递员的角色

类加载过程

类从被加载到虚拟机内存主要包括:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。(使用和卸载表示类卸载出内存为止,以上顺序都只是说开始的顺序,实际过程中是交叉的混合式进行的,加载过程中可能就已经开始验证了)

img

1. 加载(Loading):

  1. 通过一个类的全限定名获取定义此类的二进制字节流(加载到内存)
  2. 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

加载 .calss 文件的方式

  • 从本地系统中直接加载
  • 通过网络获取,典型场景:Web Applet
  • 从zip压缩文件中读取,成为日后jar、war格式的基础
  • 运行时计算生成,使用最多的是:动态代理技术
  • 由其他文件生成,比如 JSP 应用
  • 从专有数据库提取.class 文件,比较少见
  • 从加密文件中获取,典型的防 Class 文件被反编译的保护措施

2. 连接(Linking)

验证(Verify)
  • 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全
  • 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证
准备(Prepare)
  • 为static变量在方法区中分配内存空间,设置变量的初始值,例如 static int a = 3 。
  • 无初始值时,设置该类变量的默认初始值,即零值
数据类型 零值
int 0
long 0L
short (short)0
char ‘\u0000’
byte (byte)0
boolean false
float 0.0f
double 0.0d
reference null
  • 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显示初始化
  • 注意:准备阶段只设置类中的静态变量(方法区中),不包括实例变量(堆内存中),实例变量是对象初始化时赋值的
1
2
3
4
// 变量i在准备阶只会被赋值为0,初始化时才会被赋值为1
private static int i = 1;
// 这里被final修饰的变量j,直接成为常量,编译时就会被分配为2
private final static int j = 2;
解析(Resolve)
  • 将常量池内的符号引用转换为直接引用的过程
  • 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行
  • 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
  • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_info

3. 初始化(Initialization)

  • 初始化阶段就是执行类构造器方法()的过程
  • 此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来
  • 构造器方法中指令按语句在源文件中出现的顺序执行
  • ()不同于类的构造器(构造器是虚拟机视角下的())
  • 若该类具有父类,JVM会保证子类的()执行前,父类的()已经执行完毕
  • 虚拟机必须保证一个类的()方法在多线程下被同步加锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ClassInitTest{
private static int num1 = 30;
static{
num1 = 10;
// num2写在定义变量之前,为什么不会报错呢??
num2 = 10;
// 這裡直接打印可以吗?报错,非法的前向引用,可以赋值,但不可调用
System.out.println(num2);
}
// num2在准备阶段就被设置了默认初始值0,初始化阶段又将10改为20
private static int num2 = 20;
public static void main(String[] args){
System.out.println(num1); //10
System.out.println(num2); //20
}
}

类的主动使用和被动使用

Java程序对类的使用方式分为:主动使用和被动使用。虚拟机规范规定有且只有5种情况必须立即对类进行“初始化”,即类的主动使用。

  • 创建类的实例、访问某个类或接口的静态变量,或者对该静态变量赋值、调用类的静态方法(即遇到new、getstatic、putstatic、invokestatic这四条字节码指令时)
  • 反射
  • 初始化一个类的子类
  • Java虚拟机启动时被标明为启动类的类
  • JDK7 开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果,REF_getStaticREF_putStaticREF_invokeStatic句柄对应的类没有初始化,则初始化

除以上五种情况,其他使用Java类的方式被看作是对类的被动使用,都不会导致类的初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class NotInitialization {
public static void main(String[] args) {
// 只输出SupperClass int 123,不会输出SubClass init
// 对于静态字段,只有直接定义这个字段的类才会被初始化
System.out.println(SubClass.value);
}
}

class SuperClass {
static {
System.out.println("SupperClass init");
}
publicstaticint value = 123;
}

class SubClass extends SuperClass {
static {
System.out.println("SubClass init");
}
}

类加载器

  • JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)
  • 从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器

启动类加载器(引导类加载器,Bootstrap ClassLoader)

  • 这个类加载使用C/C++ 语言实现,嵌套在JVM 内部
  • 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jarresource.jarsun.boot.class.path路径下的内容),用于提供JVM自身需要的类
  • 并不继承自 java.lang.ClassLoader,没有父加载器
  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器
  • 出于安全考虑,Boostrap 启动类加载器只加载名为java、Javax、sun等开头的类

扩展类加载器(Extension ClassLoader)

  • java语言编写,由sun.misc.Launcher$ExtClassLoader实现
  • 派生于 ClassLoader
  • 父类加载器为启动类加载器
  • java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext 子目录(扩展目录)下加载类库。如果用户创建的JAR 放在此目录下,也会自动由扩展类加载器加载

应用程序类加载器(也叫系统类加载器,AppClassLoader)

  • java语言编写,由 sun.misc.Lanucher$AppClassLoader 实现
  • 派生于 ClassLoader
  • 父类加载器为扩展类加载器
  • 它负责加载环境变量classpath或系统属性java.class.path 指定路径下的类库
  • 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载的
  • 通过 ClassLoader#getSystemClassLoader() 方法可以获取到该类加载器

用户自定义类加载器

在Java的日常应用程序开发中,类的加载几乎是由3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式

为什么要自定义类加载器?
  • 隔离加载类
  • 修改类加载的方式
  • 扩展加载源(可以从数据库、云端等指定来源加载类)
  • 防止源码泄露(Java代码容易被反编译,如果加密后,自定义加载器加载类的时候就可以先解密,再加载)
用户自定义加载器实现步骤
  1. 开发人员可以通过继承抽象类 java.lang.ClassLoader 类的方式,实现自己的类加载器,以满足一些特殊的需求
  2. 在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载类,但是JDK1.2之后已经不建议用户去覆盖loadClass()方式,而是建议把自定义的类加载逻辑写在findClass()方法中
  3. 编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁

ClassLoader常用方法

ClassLoader类,是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器)

方法 描述
getParent() 返回该类加载器的超类加载器
loadClass(String name) 加载名称为name的类,返回java.lang.Class类的实例
findClass(String name) 查找名称为name的类,返回java.lang.Class类的实例
findLoadedClass(String name) 查找名称为name的已经被加载过的类,返回java.lang.Class类的实例
defineClass(String name, byte[] b, int off, int len) 把字节数组b中内容转换为一个Java类,返回java.lang.Class类的实例
resolveClass(Class<?> c) 连接指定的一个Java类

对类加载器的引用

JVM必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。

双亲委派机制

Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类的时候才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交给父类处理,它是一种任务委派模式。

工作过程

  • 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
  • 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
  • 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式

image-20200409144840425

优势

  • 避免类的重复加载,加载位于rt.jar包中的类时不管是哪个加载器加载,最终都会委托到BootStrap ClassLoader进行加载,这样保证了使用不同的类加载器得到的都是同一个结果。
  • 保护程序安全,防止核心API被随意篡改,避免用户自己编写的类动态替换 Java的一些核心类,比如我们自定义类:java.lang.String

在JVM中表示两个class对象是否为同一个类存在两个必要条件:

  • 类的完成类名必须一致,包括包名
  • 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同

沙箱安全机制

如果我们自定义String类,但是在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java\lang\String.class),报错信息说没有main方法就是因为加载的是rt.jar包中的String类。这样就可以保证对java核心源代码的保护,这就是简单的沙箱安全机制。

1
2
3
4
5
// 例如自定义的String类
public class String(){
//报错说没有main方法就是因为加载的是`rt.jar`包中的String类。
public static void main(){sout;}
}

破坏双亲委派模型

  • 双亲委派模型并不是一个强制性的约束模型,而是Java设计者推荐给开发者的类加载器实现方式,可以“被破坏”,只要我们自定义类加载器,重写loadClass()方法,指定新的加载逻辑就破坏了,重写findClass()方法不会破坏双亲委派。
  • 双亲委派模型有一个问题:顶层ClassLoader,无法加载底层ClassLoader的类。典型例子JNDI、JDBC,所以加入了线程上下文类加载器(Thread Context ClassLoader),可以通过Thread.setContextClassLoaser()设置该类加载器,然后顶层ClassLoader再使用Thread.getContextClassLoader()获得底层的ClassLoader进行加载。
  • Tomcat中使用了自定ClassLoader,并且也破坏了双亲委托机制。每个应用使用WebAppClassloader进行单独加载,他首先使用WebAppClassloader进行类加载,如果加载不了再委托父加载器去加载,这样可以保证每个应用中的类不冲突。每个tomcat中可以部署多个项目,每个项目中存在很多相同的class文件(很多相同的jar包),他们加载到jvm中可以做到互不干扰。
  • 利用破坏双亲委派来实现代码热替换(每次修改类文件,不需要重启服务)。因为一个Class只能被一个ClassLoader加载一次,否则会报java.lang.LinkageError。当我们想要实现代码热部署时,可以每次都new一个自定义的ClassLoader来加载新的Class文件。JSP的实现动态修改就是使用此特性实现。

运行时数据区详解

线程私有的:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的:

  • 方法区
  • 直接内存 (非运行时数据区的一部分)

程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。

程序计数器主要有两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

Java 虚拟机栈

Java 虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型,每次方法调用的数据都是通过栈传递的。

实际上,Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。Java 内存可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。

局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 异常。
  • OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出 OutOfMemoryError 异常。

扩展:那么方法/函数如何调用?

Java 栈可用类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。

Java 方法有两种返回方式:

  1. return 语句。
  2. 抛出异常。

不管哪种返回方式都会导致栈帧被弹出。

本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是:

  1. 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,
  2. 而本地方法栈则为虚拟机使用到的 Native 方法服务。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

img

上图所示的 eden 区、s0 区、s1 区都属于新生代,tentired 区属于老年代。大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区也被称为永久代。

在 JDK 1.8 之后,原来永久代的数据被分到了堆和元空间中。元空间存储类的元信息,静态变量和常量池等放入堆中。

方法区和永久代的关系

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久带这一说法。

  • HotSpot 是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。

常用参数

JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小

1
2
-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

JDK 1.8 之后的元空间参数:

1
2
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。

为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?

整个永久代有一个 JVM 本身设置固定大小上线,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,并且永远不会得到 java.lang.OutOfMemoryError。

你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,当使用永久代来进行垃圾回收,很难确定永久代的大小,因为它受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常。为了更容易管理方法区,从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。

运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号的引用,这部分内容将在类加载后进入方法区的运行时常量池中存放)。

JDK1.7 及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。

img——图片来源:https://blog.csdn.net/wangbiao007/article/details/78545189

举个栗子:

1
2
3
4
5
6
7
8
public static void main(String[] args) {
String s1 = "abc";
String s2 = "abc";
System.out.println(s1 == s2); //true
String s3 = new String("abc");
System.out.println(s1 == s3) ; //false
System.out.println(s1 == s3.intern()); //true
}

如上,s1s2由于是直接初始化的相同的String类型变量,这些变量储存在常量池中(类似HashSet无序唯一),因此她们的内存地址是相同的。而s3是显示的通过new关键字初始化,那么s3所代表的对象就直接在堆内存中存放,所以s3和另外两个的内存地址都不同。

运行时常量池不要求一定只有在编译器产生的才能进入,运行期间也可以将新的常量放入池中,这种特性被开发人员利用比较多的就是String.intern()方法,这个方法可以将储存在堆内存中的对象放入到常量池中,所以此时的s3和s1内存地址相同。

而String类有一个intern()方法,这个方法可以将储存在堆内存中的对象放入到常量池中,所以此时的s3s1内存地址相同。

常量池的好处

常量池是为了避免频繁的创建和销毁对象而影响系统性能,它也实现了对象的共享。

字符串常量池:在编译阶段就把所有字符串文字放到一个常量池中。

  • 节省内存空间:常量池中如果有对应的字符串,那么则返回该对象的引用,从而不必再次创建一个新对象。
  • 节省运行时间:比较字符串时,== 比equals()快。对于两个引用变量,== 判断引用是否相等,也就可以判断实际值是否相等。

双等号(==)的含义
- 基本数据类型之间使用双等号,比较的是数值。
- 复合数据类型(类)之间使用双等号,比较的是对象的引用地址是否相等。

基本类型的包装类和常量池

Byte、Short、Integer、Long、Character、Boolean、String这7种包装类都各自实现了自己的常量池。

1
2
3
4
//例子:
Integer i1 = 20;
Integer i2 = 20;
System.out.println(i1=i2);//输出TRUE

Byte、Short、Integer、Long、Character这5种包装类都默认创建了数值[-128 , 127]的缓存数据。当对这5个类型的数据不在这个区间内的时候,将会去创建新的对象,并且不会将这些新的对象放入常量池中。

1
2
3
4
5
6
7
8
9
10
11
//IntegerCache.low = -128
//IntegerCache.high = 127
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
//例子
Integer i1 = 200;
Integer i2 = 200;
System.out.println(i1==i2);//返回FALSE

Float 和Double 没有实现常量池。

String包装类与常量池

1
2
3
// 先检查字符串常量池中有没有"aaa",如果字符串常量池中没有,则创建一个,
// 然后 str1 指向字符串常量池中的对象,如果有,则直接将 str1 指向"aaa"
String str1 = "aaa";

当以上代码运行时,JVM会到字符串常量池查找 “aaa” 这个字面量对象是否存在?

  • 存在:则返回该对象的引用给变量 str1
  • 不存在:则在堆中创建一个相应的对象,将创建的对象的引用存放到常量池中,同时将引用返回给变量 str1
1
2
3
String str1 = "aaa";	
String str2 = "aaa";
System.out.println(str1 == str2); // 返回TRUE

因为变量str1str2 都指向同一个对象,所以返回true。

1
2
String str3 = new String("aaa");	// 堆中创建一个新的对象
System.out.println(str1 == str3); // 返回FALSE

当我们使用了new来构造字符串对象的时候,不管字符串常量池中是否有相同内容的对象的引用,新的字符串对象都会创建。因为两个指向的是不同的对象,所以返回FALSE 。

String-Pool-Java

String.intern()方法

String 类型的常量池比较特殊。它的主要使用方法有两种:

  • 直接使用双引号声明出来的 String 对象会直接存储在常量池中。
  • 如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern 方法。String.intern() 是一个 Native 方法,它的作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,则在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用。
1
2
3
4
5
6
String s1 = new String("计算机");
String s2 = s1.intern();
String s3 = "计算机";
System.out.println(s2);//计算机
System.out.println(s1 == s2);//false,因为一个是堆内存中的 String 对象一个是常量池中的 String 对象,
System.out.println(s3 == s2);//true,因为两个都是常量池中的 String 对象

对于使用了new 创建的字符串对象,如果想要将这个对象引用到字符串常量池,可以使用intern() 方法。调用intern() 方法后,检查字符串常量池中是否有这个对象的引用,并做如下操作:

  • 存在:直接返回对象引用给变量。
  • 不存在:将这个对象引用加入到常量池,再返回对象引用给变量。
1
2
String interns = str3.intern();
System.out.println(interns == str1); // 返回TRUE

假定常量池中都没有以下字面量的对象,以下创建了多少个对象呢?

1
2
3
4
5
6
7
8
String str1 = "abc";
String str2 = "efg";
String str3 = "abc" + "efg";//常量池中的对象
String str4 = str1 + str2; //在堆上创建的新的对象
String str5 = "abcefg";//常量池中的对象
System.out.println(str3 == str4);//false
System.out.println(str3 == str5);//true
System.out.println(str4 == str5);//false

答案是三个。第一个:”abc” ,第二个:”efg”,第三个:”abc”+”efg”(”abcefg”)

String str5 = “abcefg”; 这句代码并没有创建对象,它从常量池中找到了”abcefg” 的引用,所有str3 == str5 返回TRUE,因为它们都指向一个相同的对象。

什么情况下会将字符串对象引用自动加入字符串常量池?

1
2
3
4
5
6
7
8
9
10
11
//只有在这两种情况下会将对象引用自动加入到常量池
String str1 = "aaa";
String str2 = "aa"+"a";

//其他方式下都不会将对象引用自动加入到常量池,如下:
String str3 = new String("aaa");
String str4 = New StringBuilder("aa").append("a").toString();
StringBuilder sb = New StringBuilder();
sb.append("aa");
sb.append("a");
String str5 = sb.toString();

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 异常出现。

JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据

本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

对象的创建

image-20200409200827633

Step1:类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

Step2:分配内存

类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式“指针碰撞”“空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

内存分配的两种方式:(补充内容,需要掌握)

选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是”标记-清除”,还是”标记-整理”(也称作”标记-压缩”),值得注意的是,复制算法内存也是规整的

内存分配的两种方式

内存分配并发问题(补充内容,需要掌握)

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配

Step3:初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

Step4:设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

Step5:执行 init 方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局

对象在内存中的布局可以分为 3 块区域:对象头实例数据对齐填充

对象头包括两部分信息第一部分用于存储对象自身的自身运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问定位

建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有①使用句柄②直接指针两种:

  • 句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;

image-20200409201317229

  • 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。

image-20200409201401441

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

实例分析

User类

1
2
3
4
public class User {
private String name;
private int age;
}

测试类:

1
2
3
4
5
6
7
public class UserTest {
public static void main(String[] args) {
User user = new User();
user.setName("User");
System.out.println(user);
}
}

流程图示

我来宏观简述一下我们的例子中的工作流程:

  • 1、通过java.exe运行UserTest.class,随后被加载到JVM中,元空间存储着类的信息(包括类的名称、方法信息、字段信息..)。
  • 2、然后JVM找到UserTest的主函数入口(main),为main函数创建栈帧,开始执行main函数
  • 3、main函数的第一条命令是User user = new User();就是让JVM创建一个User对象,但是这时候方法区中没有User类的信息,所以JVM马上加载User类,把USer类的类型信息放到方法区中(元空间)
  • 4、加载完User类之后,Java虚拟机做的第一件事情就是在堆区中为一个新的User实例分配内存, 然后调用构造函数初始化User实例,这个User实例持有着指向方法区的User类的类型信息(其中包含有方法表,java动态绑定的底层实现)的引用
  • 5、当使用user.setName("User");的时候,JVM根据user引用找到User对象,然后根据User对象持有的引用定位到方法区中User类的类型信息的方法表,获得setName()函数的字节码的地址
  • 6、为setName()函数创建栈帧,开始运行setName()函数

GC机制

堆空间

当我们new一个对象后,会先放到Eden划分出来的一块作为存储空间的内存,但是我们知道对堆内存是线程共享的,所以有可能会出现两个对象共用一个内存的情况。这里JVM的处理是每个线程都会预先申请好一块连续的内存空间并规定了对象存放的位置,而如果空间不足会再申请多块内存空间。这个操作我们会称作TLAB。

当Eden空间满了之后,会触发一个叫做Minor GC(就是一个发生在年轻代的GC)的操作,存活下来的对象移动到Survivor0区。Survivor0区满后触发 Minor GC,就会将存活对象移动到Survivor1区,此时还会把from和to两个指针交换,这样保证了一段时间内总有一个survivor区为空且to所指向的survivor区为空。经过多次的 Minor GC后仍然存活的对象(这里的存活判断是15次,对应到虚拟机参数为 -XX:TargetSurvivorRatio 。为什么是15,因为HotSpot会在对象投中的标记字段里记录年龄,分配到的空间仅有4位,所以最多只能记录到15)会移动到老年代。老年代是存储长期存活的对象的,占满时就会触发我们最常听说的Full GC,期间会停止所有线程等待GC的完成。所以对于响应要求高的应用应该尽量去减少发生Full GC从而避免响应超时的问题。

而且当老年区执行了full gc之后仍然无法进行对象保存的操作,就会产生OOM,这时候就是虚拟机中的堆内存不足,原因可能会是堆内存设置的大小过小,这个可以通过参数-Xms、-Xms来调整。也可能是代码中创建的对象大且多,而且它们一直在被引用从而长时间垃圾收集无法收集它们。

img

判断对象是否要被清除

img

图中程序计数器、虚拟机栈、本地方法栈,3个区域随着线程的生存而生存的。内存分配和回收都是确定的。随着线程的结束内存自然就被回收了,因此不需要考虑垃圾回收的问题。而Java堆和方法区则不一样,各线程共享,内存的分配和回收都是动态的。因此垃圾收集器所关注的都是堆和方法这部分内存。

两个基础的计算方法:

  1. 引用计数法:给对象添加一个引用计数器,每次引用这个对象时计数器加一,引用失效时减一,计数器等于0时就是不会再次使用的。不过这个方法有一种情况就是出现对象的循环引用时GC没法回收。
  2. 可达性分析法:这是一种类似于二叉树的实现,将一系列的GC ROOTS作为起始的存活对象集,从这个节点往下搜索,搜索所走过的路径成为引用链,把能被该集合引用到的对象加入到集合中。搜索当一个对象到GC Roots没有使用任何引用链时,则说明该对象是不可用的。

在Java语言汇总能作为GC Roots的对象分为以下几种:(了解)

  1. 虚拟机栈(栈帧中的本地方法表)中引用的对象(局部变量)
  2. 方法区中静态变量所引用的对象(静态变量)
  3. 方法区中常量引用的对象
  4. 本地方法栈(即native修饰的方法)中JNI引用的对象(JNI是Java虚拟机调用对应的C函数的方式,通过JNI函数也可以创建新的Java对象。且JNI对于对象的局部引用或者全局引用都会把它们指向的对象都标记为不可回收)
  5. 已启动的且未终止的Java线程

这种方法的优点是能够解决循环引用的问题,可它的实现需要耗费大量资源和时间,也需要GC(它的分析过程引用关系不能发生变化,所以需要停止所有进程)

堆空间对象分配

1. 对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。

2. 大对象直接进入老年代

大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。

经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。

3. 长期存活的对象进入老年代

为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。

-XX:MaxTenuringThreshold 用来定义年龄的阈值。

4. 动态对象年龄判定

虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

5. 空间分配担保

在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。

如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。

Minor GC 和 Full GC

  • Minor GC:回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
  • Full GC:回收老年代和新生代,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。

垃圾回收算法

不会非常详细的展开,常用的有标记清除,复制,标记整理和分代收集算法

标记清除算法

标记清除算法就是分为“标记”和“清除”两个阶段。标记出所有需要回收的对象,标记结束后统一回收。这个套路很简单,也存在不足,后续的算法都是根据这个基础来加以改进的。

其实它就是把已死亡的对象标记为空闲内存,然后记录在一个空闲列表中,当我们需要new一个对象时,内存管理模块会从空闲列表中寻找空闲的内存来分给新的对象。

不足的方面就是标记和清除的效率比较低下。且这种做法会让内存中的碎片非常多。这个导致了如果我们需要使用到较大的内存块时,无法分配到足够的连续内存。比如下图

img

此时可使用的内存块都是零零散散的,导致了刚刚提到的大内存对象问题

复制算法

为了解决效率问题,复制算法就出现了。它将可用内存按容量划分成两等分,每次只使用其中的一块。和survivor一样也是用from和to两个指针这样的玩法。fromPlace存满了,就把存活的对象copy到另一块toPlace上,然后交换指针的内容。这样就解决了碎片的问题。

这个算法的代价就是把内存缩水了,这样堆内存的使用效率就会变得十分低下了

img

不过它们分配的时候也不是按照1:1这样进行分配的,就类似于Eden和Survivor也不是等价分配是一个道理。

标记整理算法

复制算法在对象存活率高的时候会有一定的效率问题,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存

img

分代收集算法

这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收。

各种各样的垃圾回收器(了解)

img

到jdk8为止,默认的垃圾收集器是Parallel Scavenge 和 Parallel Old

从jdk9开始,G1收集器成为默认的垃圾收集器
目前来看,G1回收器停顿时间最短而且没有明显缺点,非常适合Web应用。在jdk8中测试Web应用,堆内存6G,新生代4.5G的情况下,Parallel Scavenge 回收新生代停顿长达1.5秒。G1回收器回收同样大小的新生代只停顿0.2秒。

JVM的常用参数(了解)

JVM的参数非常之多,这里只列举比较重要的几个,通过各种各样的搜索引擎也可以得知这些信息。

JVM参数的含义

参数名称 含义 默认值 说明
-Xms 初始堆大小 物理内存的1/64(<1GB) 默认(MinHeapFreeRatio参数可以调整)空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制.
-Xmx 最大堆大小 物理内存的1/4(<1GB) 默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制
-Xmn 年轻代大小(1.4or lator) 注意:此处的大小是(eden+ 2 survivor space).与jmap -heap中显示的New gen是不同的。整个堆大小=年轻代大小 + 老年代大小 + 持久代(永久代)大小.增大年轻代后,将会减小年老代大小.此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8
-XX:NewSize 设置年轻代大小(for 1.3/1.4)
-XX:MaxNewSize 年轻代最大值(for 1.3/1.4)
-XX:PermSize 设置持久代(perm gen)初始值 物理内存的1/64
-Xss 每个线程的堆栈大小 JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K.更具应用的线程所需内存大小进行 调整.在相同物理内存下,减小这个值能生成更多的线程.但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右一般小的应用, 如果栈不是很深, 应该是128k够用的 大的应用建议使用256k。这个选项对性能影响比较大,需要严格的测试。(校长)和threadstacksize选项解释很类似,官方文档似乎没有解释,在论坛中有这样一句话:-Xss is translated in a VM flag named ThreadStackSize”一般设置这个值就可以了
-XX:NewRatio 年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代) -XX:NewRatio=4表示年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5Xms=Xmx并且设置了Xmn的情况下,该参数不需要进行设置。
-XX:SurvivorRatio Eden区与Survivor区的大小比值 设置为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10
-XX:+DisableExplicitGC 关闭System.gc() 这个参数需要严格的测试
-XX:PretenureSizeThreshold 对象超过多大是直接在旧生代分配 0 单位字节 新生代采用Parallel ScavengeGC时无效另一种直接在旧生代分配的情况是大的数组对象,且数组中无外部引用对象.
-XX:ParallelGCThreads 并行收集器的线程数 此值最好配置与处理器数目相等 同样适用于CMS
-XX:MaxGCPauseMillis 每次年轻代垃圾回收的最长时间(最大暂停时间) 如果无法满足此时间,JVM会自动调整年轻代大小,以满足此值.

其实还有一些打印及CMS方面的参数,这里就不以一一列举了

JVM调优

主要就是堆内存那块

所有线程共享数据区大小=新生代大小 + 年老代大小 + 持久代大小。持久代一般固定大小为64m。所以java堆中增大年轻代后,将会减小年老代大小(因为老年代的清理是使用fullgc,所以老年代过小的话反而是会增多fullgc的)。此值对系统性能影响较大,Sun官方推荐配置为java堆的3/8。

1 调整最大堆内存和最小堆内存

-Xmx –Xms:指定java堆最大值(默认值是物理内存的1/4(<1GB))和初始java堆最小值(默认值是物理内存的1/64(<1GB))

默认(MinHeapFreeRatio参数可以调整)空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制.,默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制。简单点来说,你不停地往堆内存里面丢数据,等它剩余大小小于40%了,JVM就会动态申请内存空间不过会小于-Xmx,如果剩余大小大于70%,又会动态缩小不过不会小于–Xms。就这么简单

开发过程中,通常会将 -Xms 与 -Xmx两个参数的配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小而浪费资源。

我们执行下面的代码

1
2
3
System.out.println("Xmx=" + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");    //系统的最大空间
System.out.println("free mem=" + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M"); //系统的空闲空间
System.out.println("total mem=" + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M"); //当前可用的总空间

2 调整新生代和老年代的比值

-XX:NewRatio — 新生代(eden+2*Survivor)和老年代(不包含永久区)的比值

例如:-XX:NewRatio=4,表示新生代:老年代=1:4,即新生代占整个堆的1/5。在Xms=Xmx并且设置了Xmn的情况下,该参数不需要进行设置。

3 调整Survivor区和Eden区的比值

-XX:SurvivorRatio(幸存代)— 设置两个Survivor区和eden的比值

例如:8,表示两个Survivor:eden=2:8,即一个Survivor占年轻代的1/10

4 设置年轻代和老年代的大小

-XX:NewSize — 设置年轻代大小

-XX:MaxNewSize — 设置年轻代最大值

可以通过设置不同参数来测试不同的情况,反正最优解当然就是官方的Eden和Survivor的占比为8:1:1,然后在刚刚介绍这些参数的时候都已经附带了一些说明,感兴趣的也可以看看。反正最大堆内存和最小堆内存如果数值不同会导致多次的gc,需要注意。

5 小总结

根据实际事情调整新生代和幸存代的大小,官方推荐新生代占java堆的3/8,幸存代占新生代的1/10

在OOM时,记得Dump出堆,确保可以排查现场问题,通过下面命令你可以输出一个.dump文件,这个文件可以使用VisualVM或者Java自带的Java VisualVM工具。

1
-Xmx20m -Xms5m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=你要输出的日志路径

一般我们也可以通过编写脚本的方式来让OOM出现时给我们报个信,可以通过发送邮件或者重启程序等来解决。

6 永久区的设置

1
-XX:PermSize -XX:MaxPermSize

初始空间(默认为物理内存的1/64)和最大空间(默认为物理内存的1/4)。也就是说,jvm启动时,永久区一开始就占用了PermSize大小的空间,如果空间还不够,可以继续扩展,但是不能超过MaxPermSize,否则会OOM。

tips:如果堆空间没有用完也抛出了OOM,有可能是永久区导致的。堆空间实际占用非常少,但是永久区溢出 一样抛出OOM。

7 JVM的栈参数调优

7.1 调整每个线程栈空间的大小

可以通过-Xss:调整每个线程栈空间的大小

JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右

7.2 设置线程栈的大小

1
2
-XXThreadStackSize:
设置线程栈的大小(0 means use default stack size)

这些参数都是可以通过自己编写程序去简单测试的,这里碍于篇幅问题就不再提供demo了

QA部分

  • 讲讲什么情况下回出现内存溢出,内存泄漏?
  • 说说Java线程栈
  • JVM 年轻代到年老代的晋升过程的判断条件是什么呢?
  • JVM 出现 fullGC 很频繁,怎么去线上排查问题?
  • 类加载为什么要使用双亲委派模式,有没有什么场景是打破了这个模式?
  • 类的实例化顺序
  • JVM垃圾回收机制,何时触发MinorGC等操作
  • JVM 中一次完整的 GC 流程(从 ygc 到 fgc)是怎样的
  • 各种回收算法
  • String s1 = new String("abc");这句话创建了几个字符串对象?

讲讲什么情况下回出现内存溢出,内存泄漏?

内存泄漏的原因很简单:

  • 对象是可达的(一直被引用)
  • 但是对象不会被使用

常见的内存泄漏例子:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
Set set = new HashSet();
for (int i = 0; i < 10; i++) {
Object object = new Object();
set.add(object);
// 设置为空,这对象我不再用了
object = null;
}
// 但是set集合中还维护这obj的引用,gc不会回收object对象
System.out.println(set);
}

解决这个内存泄漏问题也很简单,将set设置为null,那就可以避免上诉内存泄漏问题了。其他内存泄漏得一步一步分析了。

内存泄漏参考资料:

内存溢出的原因:

  • 内存泄露导致堆栈内存不断增大,从而引发内存溢出。
  • 大量的jar,class文件加载,装载类的空间不够,溢出
  • 操作大量的对象导致堆内存空间已经用满了,溢出
  • nio直接操作内存,内存过大导致溢出

解决:

  • 查看程序是否存在内存泄漏的问题
  • 设置参数加大空间
  • 代码中是否存在死循环或循环产生过多重复的对象实体、
  • 查看是否使用了nio直接操作内存。

参考资料:

说说线程栈

这里的线程栈应该指的是虚拟机栈

JVM规范让每个Java线程拥有自己的独立的JVM栈,也就是Java方法的调用栈。

当方法调用的时候,会生成一个栈帧。栈帧是保存在虚拟机栈中的,栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息

线程运行过程中,只有一个栈帧是处于活跃状态,称为“当前活跃栈帧”,当前活动栈帧始终是虚拟机栈的栈顶元素

通过jstack工具查看线程状态

参考资料:

JVM 年轻代到年老代的晋升过程的判断条件是什么呢?

  1. 部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认是15),最终如果还是存活,就存入到老年代。
  2. 如果对象的大小大于Eden的二分之一会直接分配在old,如果old也分配不下,会做一次majorGC,如果小于eden的一半但是没有足够的空间,就进行minorgc也就是新生代GC。
  3. minor gc后,survivor仍然放不下,则放到老年代
  4. 动态年龄判断 ,大于等于某个年龄的对象超过了survivor空间一半 ,大于等于某个年龄的对象直接进入老年代

JVM 出现 fullGC 很频繁,怎么去线上排查问题

这题就依据full GC的触发条件来做:

  • 如果有perm gen的话(jdk1.8就没了),要给perm gen分配空间,但没有足够的空间时,会触发full gc。

​ - 所以看看是不是perm gen区的值设置得太小了。

  • System.gc()方法的调用

​ - 这个一般没人去调用吧~

  • 统计得到的Minor GC晋升到旧生代的平均大小大于老年代的剩余空间,则会触发full gc(这就可以从多个角度上看了)

​ - 是不是频繁创建了大对象(也有可能eden区设置过小)(大对象直接分配在老年代中,导致老年代空间不足—>从而频繁gc)
​ - 是不是老年代的空间设置过小了(Minor GC几个对象就大于老年代的剩余空间了)

img

类加载为什么要使用双亲委派模式,有没有什么场景是打破了这个模式?

双亲委托模型的重要用途是为了解决类载入过程中的安全性问题

  • 假设有一个开发者自己编写了一个名为java.lang.Object的类,想借此欺骗JVM。现在他要使用自定义ClassLoader来加载自己编写的java.lang.Object类。
  • 然而幸运的是,双亲委托模型不会让他成功。因为JVM会优先在Bootstrap ClassLoader的路径下找到java.lang.Object类,并载入它

Java的类加载是否一定遵循双亲委托模型?

  • 在实际开发中,我们可以通过自定义ClassLoader,并重写父类的loadClass方法,来打破这一机制。
  • SPI就是打破了双亲委托机制的(SPI:服务提供发现)。SPI资料:

​ - https://zhuanlan.zhihu.com/p/28909673
​ - https://www.cnblogs.com/huzi007/p/6679215.html
​ - https://blog.csdn.net/sigangjun/article/details/79071850

参考资料:

类的实例化顺序

  • 父类静态成员和静态初始化块 ,按在代码中出现的顺序依次执行
  • 子类静态成员和静态初始化块 ,按在代码中出现的顺序依次执行
  • 父类实例成员和实例初始化块 ,按在代码中出现的顺序依次执行
  • 父类构造方法
  • 子类实例成员和实例初始化块 ,按在代码中出现的顺序依次执行
  • 子类构造方法

检验一下是不是真懂了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Dervied extends Base {
private String name = "Java3y";
public Dervied() {
tellName();
printName();
}

public void tellName() {
System.out.println("Dervied tell name: " + name);
}

public void printName() {
System.out.println("Dervied print name: " + name);
}

public static void main(String[] args) {

new Dervied();
}
}

class Base {
private String name = "公众号";
public Base() {
tellName();
printName();
}

public void tellName() {
System.out.println("Base tell name: " + name);
}

public void printName() {
System.out.println("Base print name: " + name);
}
}

输出数据:

1
2
3
4
Dervied tell name: null
Dervied print name: null
Dervied tell name: Java3y
Dervied print name: Java3y

JVM垃圾回收机制,何时触发MinorGC等操作

当young gen中的eden区分配满的时候触发MinorGC(新生代的空间不够放的时候).

JVM 中一次完整的 GC 流程(从 ygc 到 fgc)是怎样的

  • YGC :对新生代堆进行gc。频率比较高,因为大部分对象的存活寿命较短,在新生代里被回收。性能耗费较小。
  • FGC :全堆范围的gc。默认堆空间使用到达80%(可调整)的时候会触发fgc。以我们生产环境为例,一般比较少会触发fgc,有时10天或一周左右会有一次。

什么时候执行YGC和FGC

  • a.eden空间不足,执行 young gc
  • b.old空间不足,perm空间不足,调用方法System.gc() ,ygc时的悲观策略, dump live的内存信息时(jmap –dump:live),都会执行full gc

各种回收算法

GC最基础的算法有三种:

  • 标记 - 清除算法
  • 复制算法
  • 标记 - 整理算法
  • 我们常用的垃圾回收器一般都采用分代收集算法(其实就是组合上面的算法,不同的区域使用不同的算法)。

具体:

  • 标记-清除算法,“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。
  • 复制算法,“复制”(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
  • 标记-整理算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
  • 分代收集算法,“分代收集”(Generational Collection)算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

String s1 = new String("abc");这句话创建了几个字符串对象?

将创建 1 或 2 个字符串。如果池中已存在字符串文字abc,则池中只会创建一个字符串对象引用s1指向了字符串常量中的abc。如果池中没有字符串文字abc,那么它将首先在池中创建abc,然后在堆空间中创建String对象(每次new都会在堆内存开辟空间),因此将创建总共 2 个字符串对象。

验证:

1
2
3
4
String s1 = new String("abc");// 堆内存的地址值
String s2 = "abc";
System.out.println(s1 == s2);// 输出 false,因为一个是堆内存,一个是常量池的内存,故两者是不同的。
System.out.println(s1.equals(s2));// 输出 true

结果:

1
2
false
true

补充:

String s = "a"; s += "b";,这段代码执行前后,字符串常量池中将出现aab两个字符串常量,而原本s变量的引用指向了常量池中ab