Fork me on GitHub

jvm——秋招复习笔记

[TOC]

基础

jvm运行时数据区?谈谈1.7永久代被移除

1564542129113

程序计数器有两个作用

  1. 字节码解释器通过改变程序计数器的值来实现代码的流程控制
  2. 为了在线程切换后每条线程都能正确回到上次执行的位置,因为每条线程都有自己的程序计数器。

虚拟机栈是存放Java方法内存模型,每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法返回地址等信息。方法的开始调用对应着栈帧的进栈,方法执行完成对应这栈帧的出栈。位于栈顶被称为“当前方法”。

本地方法栈和虚拟机栈类似,不过虚拟机栈针对Java方法,而本地方法栈针对Native方法。

Java堆。对象实例被分配内存的地方,也是垃圾回收的主要区域。

方法区。存放被虚拟机加载的类信息、常量final、静态变量static、即时编译期编译后的代码
1.7之前方法区是用永久代实现的。
这个区域的内存回收目标主要是针对常量池的回收和类型的卸载。
运行时常量池是方法区的一部分,运行时常量池是Class文件中的一项信息,存放编译期生成的各种字面量和符号引用。

常量池在JDK6之前存于永久代中,而后被移动到了堆中,这是因为永久代空间有限,如果频繁创建字符串对象会使得字符串常量池被挤爆,进而引发永久代异常。

jdk1.7永久代被移除, 方法区移至Metaspace,字符串常量移至Java Heap。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存

1564552178623

替换的好处:一、字符串存在永久代中,容易出现性能问题和内存溢出。而元空间用户内存有多大就可以用多大。

二、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低

从一个完整的类来看Java内存结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class HelloWorld {
private String name;//成员变量等类信息存在元空间

public void sayHello() {//方法等类信息存在元空间
System.out.println("Hello"+name);
}

public void sayHello(String name) {//方法等类信息存在元空间
this.name = name;
}

public static void main(String[] args) {//main对应虚拟机栈中的一个栈帧
int a=1;//局部变量存在虚拟机栈的局部变量表
HelloWorld hw=new HelloWorld();//生成的实例存储在堆,此外hw这个地址的引用存在局部变量表
hw.setName("test");//?先生成一个“test”字符串对象,然后把值存在堆中
hw.sayHello();//调用sayHello方法,对应虚拟机栈中的一个栈帧
}

}

以JDK8来分析

元空间:HelloWorld的类信息包括成员变量name、方法sayHello和sayHello会存储在元空间;还有System类

堆:HelloWorld hw=new HelloWorld();会在堆中创建一个HelloWorld的实例;String(“test”);

虚拟机栈:“test”引用变量;“hw”保存HelloWorld实例的地址引用变量;局部变量a=1;

jvm运行时数据区中的堆和栈

  • 静态存储——是指在编译时就能够确定每个数据目标在运行时的存储空间需求,因而在编译时就可以给它们分配固定的内存空间。不允许有可变数据结构、嵌套或者递归,因为它们都会导致编译程序无法计算准确的存储空间。
  • 栈式存储——该分配可成为动态存储分配,是由一个类似于堆栈的运行栈来实现的,和静态存储的分配方式相反,在栈式存储方案中,程序对数据区的需求在编译时是完全未知的,只有到了运行的时候才能知道,但是规定在运行中进入一个程序模块的时候,必须知道该程序模块所需要的数据区的大小才能分配其内存。和我们在数据结构中所熟知的栈一样,栈式存储分配按照先进后出的原则进行分配。
  • 堆式存储——堆式存储分配则专门负责在编译时或运行时模块入口处都无法确定存储要求的数据结构的内存分配,比如可变长度串和对象实例,堆由大片的可利用块或空闲块组成,堆中的内存可以按照任意顺序分配和释放。

联系

数组或对象在堆创建之后,可以在栈中定义一个特殊的变量,它取值等于数组或对象在堆内存中的首地址,栈中的这个变量就成了数组或对象的引用变量,它相当于为数组或对象起的一个名称,以后就可以在程序中使用栈中的引用变量来访问堆中的数组或对象。 引用变量相当于为数组或者对象起的一个别名,或者代号。实际上,栈中的变量指向堆内存中的变量,这就是 Java 中的指针。

引用变量是普通变量,定义时在栈中分配内存,引用变量在程序运行到作用域外释放。而数组或对象本身在堆中分配,即使程序运行到使用new产生数组和对象的语句所在地代码块之外,数组和对象本身占用的堆内存也不会被释放,数组和对象在没有引用变量指向它的时候,才变成垃圾,不能再被使用,但是仍然占着内存,在随后的一个不确定的时间被垃圾回收器释放掉。

区别

  1. 管理方式:堆GC回收,栈方法结束自动释放
  2. 存储内容:堆存放对象、栈存放方法
  3. 空间大小:堆比栈大
  4. 碎片相关:堆碎片更多,毕竟栈是一个简单的单向存储结构
  5. 分配方式:堆只支持动态分配,栈支持静态分配和动态分配。
  6. 效率:堆比栈效率低,栈就入栈出栈两个操作

一个方法被调用的过程?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
   public  int s() {
int i=100;
int j=300;
int k=i+j;
return k;
}
对应的字节码
public int s();
Code:
0: bipush 100
2: istore_1
3: sipush 300
6: istore_2
7: iload_1
8: iload_2
9: iadd
10: istore_3
11: iload_3
12: ireturn

赋值:bipush200被加载进操作数栈中,istore将100放入局部变量表的第一个Slot中。之后的200也是同样操作。

操作:iload_1好iload_2将100和200分别被压入操作数栈中,iadd两个栈顶元素出栈做整形加法,istore_3将300这个结果放入局部变量表第三个Slot中,300入操作数栈。ireturn返回。

整个运算过程中间变量都以操作数栈的入栈出栈作为信息交换途径。

泛型

Java的泛型是伪泛型,在编译期间,所有的泛型信息都会被擦掉。生成的字节码中是不包含泛型中的类型信息的,使用泛型的时候加上类型参数,在编译器编译的时候会去掉,这个过程成为类型擦除。

如在代码中定义List<Object>List<String>等类型,在编译后都会变成List,JVM看到的只是List,而由泛型附加的类型信息对JVM是看不到的。Java编译器会在编译时尽可能的发现可能出错的地方,但是仍然无法在运行时刻出现的类型转换异常的情况,类型擦除也是Java的泛型与C++模板机制实现方式之间的重要区别。

一个简单问题

1
2
3
4
5
6
7
public static void main(String[] args) {
ArrayList<String> l1=new ArrayList<>();
ArrayList<Integer> l2=new ArrayList<>();
l1.add("1");
l2.add(1);
System.out.println(l1.getClass()==l1.getClass());
}

答案是true,因为泛型会进行类型消除。

当泛型遇上重载

1
2
3
4
5
6
public static void method(List<String> list) {
System.out.println("method(List<String> list)");
}
public static void method(List<Integer> list) {
System.out.println("method(List<Integer> list)");
}

编译不通过,因为泛型只是语法糖,参数List list和List list编译之后都被擦除了,变成了一样的原生类型List,擦除动作导致这两种方法的特征签名变得一模一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static String method(List<String> list) {
System.out.println("method(List<String> list)");
return "";
}

public static int method(List<Integer> list) {
System.out.println("method(List<Integer> list)");
return 1;
}
public static void main(String[] args) {
method(new ArrayList<String>());
method(new ArrayList<Integer>());
}

运行结果:
method(List list)
method(List list)

可见运行成功了。

两个方法的差别主要在于返回值不同。但是重载必须是两个方法方法签名不同,而与返回值无关,返回值并不包含在方法签名里。所以上面代码并不是发生了方法重载。

在Class文件格式中,特征签名的范围更大一些,只要描述符(作用是描述方法的参数列表和返回值)不是完全一致的两个方法就可以共存。也就是说,两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法地共存在一个Class文件中的,只是没有发生重载。

对象与类

对象的创建?对象创建

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

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

③初始化零值: 将分配到的内存空间都初始化为零值(对象头在下一部初始化),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用。与类加载的初始化零值区别在于,类加载初始化是执行方法,即类中所有类变量的赋值动作和static{},调用前会先执行父类的方法。而对象初始化的方法即我们自己写的构造函数,调用前会先执行父类的构造函数。

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

⑤执行 init 方法: 执行<init> 构造方法。

对象的内存布局多线程概念

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

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

对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须的,仅仅是为了字节对齐;

对象的访问定位

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

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

    使用句柄

  1. 直接指针: 如果使用直接指针访问,那么 Java 堆对像的布局中就必须考虑如何防止放置类型数据的相关信息(如对象的类型,实现的接口、方法、父类、field等),reference 中存储的直接就是对象的地址。

使用直接指针

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

对于HotSpot虚拟机来说,使用的就是直接指针访问的方式。

介绍下类加载器和类加载过程?

先说类加载器

在Java中,系统提供了三种类加载器。

  • 启动类加载器(Bootstrap ClassLoader),启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要委派给启动类加载器,直接使用null。
  • 扩展类加载器(Extension ClassLoader)
  • 应用程序类加载器(Application ClassLoader),负责加载用户类路径(ClassPath)上锁指定的类库。是程序中默认的类加载器。

当然用户也可以自定义类加载器。

再说类加载的过程

主要是以下几个过程:

加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载

加载

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

验证

  • 文件格式验证:比如检查是否以魔数0xCAFEBABE开头
  • 元数据验证:对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息。比如检查该类是否继承了被final修饰的类。
  • 字节码验证,通过数据流和控制流的分析,验证程序语义是合法的、符合逻辑的。

准备。 为类变量(static)分配内存并设置默认值。比如static int a = 123在准备阶段的默认值是0,但是如果有final修饰,在准备阶段就会被赋值为123了。

解析。将常量池中的符号引用替换成直接引用的过程。包括类或接口、字段、类方法、接口方法的解析。

初始化。按照程序员的计划初始化类变量。如static int a = 123,在准备阶段a的值被设置为默认的0,而到了初始化阶段其值被设置为123。

什么是双亲委派模型,有什么好处?如何打破双亲委派模型?

类加载器之间满足双亲委派模型,即:除了顶层的启动类加载器外,其他所有类加载器都必须要自己的父类加载器。当一个类加载器收到类加载请求时,自己首先不会去加载这个类,而是不断把这个请求委派给父类加载器完成,因此所有的加载请求最终都传递给了顶层的启动类加载器。只有当父类无法完成这个加载请求时,子类加载器才会尝试自己去加载。

双亲委派模型的好处?使得Java的类随着它的类加载器一起具备了一种带有优先级的层次关系。Java的Object类是所有类的父类,因此无论哪个类加载器都会加载这个类,因为双亲委派模型,所有的加载请求都委派给了顶层的启动类加载器进行加载。所以Object类在任何类加载器环境中都是同一个类。

如何打破双亲委派模型?使用OSGi可以打破。OSGI(Open Services Gateway Initiative),或者通俗点说JAVA动态模块系统。可以实现代码热替换、模块热部署。在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构。

垃圾回收

新生代和老年代是什么?对象如何进入老年代?

Java堆分为新生代和老年代。在新生代又被划分为Eden区,From Sruvivor和To Survivor区,比例是8:1:1,所以新生代可用空间其实只有其容量的90%。对象优先被分配在Eden区。

  • 长期存活的对象会进入老年代。在Eden区出生的对象经过一次Minor GC会若存活,且Survivor区容纳得下,就会进入Survivor区且对象年龄加1,当对象年龄达到一定的值,就会进入老年代。
  • 大对象比如长字符串、数组由于需要大量连续的内存空间,所以直接进入老年代。这是对象进入老年代的一种方式,
  • 若Survivor区不能容纳存活的对象,则会通过分配担保机制转移到老年代。
  • 同年龄的对象达到suivivor空间的一半,大于等于该年龄的对象会直接进入老年代。

新生代的和老年代什么时候会发生GC?

发生在新生代的GC称为Minor GC,当Eden区被占满了而又需要分配内存时,会发生一次Minor GC,一般使用复制算法,将Eden和From Survivor区中还存活的对象一起复制到To Survivor区中,然后一次性清理掉Eden和From Survivor中的内存,使用复制算法不会产生碎片。

老年代的GC称为Full GC或者Major GC:

  • 当老年代的内存占满而又需要分配内存时,会发起Full GC

  • 调用System.gc()时,可能会发生Full GC,并不保证一定会执行。

  • 在Minor GC后survivor区放不下,通过担保机制进入老年代的对象比老年代的内存空间还大,会发生Full GC;

  • ·在发生Minor GC之前,会先比较历次晋升到老年代的对象平均年龄,如果大于老年代的内存,也会触发Full GC。如果不允许担保失败,直接Full GC。

对象在什么时候可以被回收?调用finalize方法后一定会被回收吗?

在经过可达性分析后,到GC Roots不可达的对象可以被回收(但并不是一定会被回收,至少要经过两次标记),此时对象被第一次标记,并进行一次判断:

如果该对象没有调用过或者没有重写finalize()方法,那么在第二次标记后可以被回收了;

否则,该对象会进入一个FQueue中,稍后由JVM建立的一个Finalizer线程中去执行回收,此时若对象中finalize中“自救”,即和引用链上的任意一个对象建立引用关系,到GC Roots又可达了,在第二次标记时它会被移除“即将回收”的集合;如果finalize中没有逃脱,那就面临被回收。

因此finalize方法被调用后,对象不一定会被回收。

GC一定会导致停顿吗,为什么一定要停顿?任意时候都可以GC吗还是在特定的时候?

GC进行时必须暂停所有Java执行线程,这被称为Stop The World。为什么要停顿呢?因为可达性分析过程中不允许对象的引用关系还在变化,否则可达性分析的准确性就无法得到保证。所以需要STW以保证可达性分析的正确性。

程序执行时并非在所有地方都能停顿下来开始GC,只有在“安全点”才能暂停。安全点指的是:HotSpot没有为每一条指令都生成OopMap(Ordinary Object Pointer),而是在一些特定的位置记录了这些信息。这些位置就叫安全点。

安全点表示所有的工作线程都停了

哪些对象可以作为GC Roots?

  • 虚拟机栈中引用的对象(栈帧中的本地变量表)

  • 方法区中类静态属性引用的对象(static)

  • 方法区中常量引用的对象(final)

  • 本地方法栈中引用的对象