JVM
1 说一下JVM 的主要组成部分及其作用?
JVM包含两个子系统和两个组件:
-
两个子系统为Class loader(类装载)、Execution engine(执行引擎);
-
两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)。
-
Class loader(类装载):
根据给定的全限定名类名(如:java.lang.Object)来装载class文件到运行时数据区中的方法区
-
Execution engine(执行引擎):执行classes中的指令。
-
Native Interface(本地接口):与native libraries交互,是其它编程语言交互的接口。
-
Runtime data area(运行时数据区域):这就是我们常说的JVM的内存。
流程 :
-
首先通过编译器把 Java 代码转换成字节码
-
类加载器(ClassLoader)把字节码加载到内存,将其放在运行时数据区(Runtime data area)的方法区
-
而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行
-
而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。
下面是Java程序运行机制详细说明
Java程序运行机制步骤
-
首先利用IDE集成开发工具编写Java源代码,源文件的后缀为.java;
-
再利用编译器(javac命令)将源代码编译成字节码文件,字节码文件的后缀名为.class;
-
运行字节码的工作是由解释器(java命令)来完成的。
从上图可以看出,java文件通过编译器变成了.class文件,类加载器又将.class文件加载到JVM中。
2 介绍下 Java 内存区域(运行时数据区)
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。JDK. 1.8 和之前的版本略有不同,下面会介绍到。
JDK 1.8 之前:
JDK 1.8 :
线程共享的:
-
堆
-
方法区(JDK1.8后放在了原空间)
-
直接内存 (非运行时数据区的一部分)
线程私有的:
-
程序计数器
-
虚拟机栈
-
本地方法栈
2.1 程序计数器PC
程序计数器有两个作用:
-
字节码解释器通过改变程序计数器来依次读取指令从而实现代码的流程控制,如:顺序执行、循环、异常处理。
-
多线程情况,用于记录当前线程执行的位置,线程被切换回来的时候能够知道该线程上次运行到哪了。
注意:程序计数器是唯一一个不会出现内存溢出 OutOfMemoryError
的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
2.2 Java 虚拟机栈
Java 内存总的来说可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)
Java 虚拟机栈会出现两种错误:栈溢出StackOverFlowError
和 内存溢出OutOfMemoryError
-
StackOverFlowError
: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。 -
OutOfMemoryError
: 若 Java 虚拟机堆中没有空闲内存,并且垃圾回收器也无法提供更多内存的话。就会抛出 OutOfMemoryError 错误。
扩展:那么方法/函数如何调用?
Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。
Java 方法有两种返回方式:
-
return 语句。
-
抛出异常。
不管哪种返回方式都会导致栈帧被弹出。
2.3 本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
2.4 堆
Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
如果某些方法中的对象引用没有被返回或者未被外面使用,那么对象可以直接在栈上分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老生代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
在 JDK 7 版本及JDK 7 版本之前,堆内存被通常被分为下面三部分:
-
新生代内存(Young Generation)
-
老生代(Old Generation)
-
永生代(Permanent Generation)
JDK 1.8后方法区(HotSpot 的永生代)被彻底移除,取而代之是元空间,元空间使用直接内存
上图所示的 Eden 区、两个 Survivor 区都属于新生代(为了区分,这两个 Survivor 区域按照顺序被命名为 from 和 to),中间一层属于老年代。
堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:
-
OutOfMemoryError: GC Overhead Limit Exceeded
: 当JVM花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。 -
java.lang.OutOfMemoryError: Java heap space
:假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和本机物理内存无关,和你配置的内存大小有关!) -
......
2.5 方法区
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
方法区也被称为永久代。很多人都会分不清方法区和永久代的关系,为此我也查阅了文献。
2.5.1 方法区和永久代的关系
方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,其他的虚拟机实现并没有永久代这一说法。
2.5.2 常用参数
JDK 1.8 的时候方法区(HotSpot 的永久代)被彻底移除,换成元空间,元空间使用的是直接内存。
下面是一些常用参数:
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小)大小,如果不指定,则 Metaspace 将根据运行时的应用程序需求动态调整。
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小,默认值为 unlimited
与永久代的不同:如果不指定大小,随着更多类的创建,虚拟机会耗尽所有可用的系统内存
2.5.3 为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍可能溢出,但几率更小。
当元空间溢出时会得到如下错误: `java.lang.OutOfMemoryError: MetaSpace
2.6 运行时常量池
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用)
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。
JDK1.7之前运行时常量池逻辑包含字符串常量池,存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代
JDK1.7 字符串常量池被从方法区拿到了堆中, 运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代 。
JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)
2.7 直接内存
直接内存不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。
本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
延申:说一下堆栈的区别?
-
物理地址:
堆的物理地址分配对对象是不连续的。因此性能慢些。
栈使用的是数据结构中的栈,先进后出的原则,物理地址分配是连续的。所以性能快。
-
内存分配时间:
堆分配的内存在
运行期
确认,大小不固定。一般堆大小远远大于栈。栈分配的内存在
编译期
确认,大小固定。 -
存放的内容
堆存放的是对象的实例和数组,更关注数据的存储
栈存放:局部变量,操作数栈,返回结果。更关注的程序方法的执行。
ps:
-
静态变量放在方法区
-
静态的对象放在堆。
-
-
程序的可见度
堆对于整个应用程序共享、可见。
栈只对线程可见,线程私有
3 Java 对象的创建过程
通过上面的介绍我们大概知道了虚拟机的内存情况,下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。
3.1 对象的创建(重要、默写)
Step1:类加载检查
虚拟机遇到一条 new 指令时,首先去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那先执行相应的类加载过程。
Step2:分配内存
类加载检查通过后,虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。
内存分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又取决于所采用的垃圾收集器是否带有压缩整理功能。
也就是说:取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称"标记-压缩"),值得注意的是,复制算法内存也是规整的
内存分配的两种方式:(需要掌握)
栈上分配以及TLAB
我们知道,一般在java程序中,new的对象是分配在堆空间中的,但是实际的情况是,大部分的new对象会进入堆空间中,而并非是全部的对象,还有另外两个地方可以存储new的对象,我们称之为栈上分配以及TLAB
-
栈上分配:在我们的应用程序中,其实有很多的对象的作用域都不会逃逸出方法外,也就是说该对象的生命周期会随着方法的调用开始而开始,方法的调用结束而结束,对于这种对象,可以考虑将对象不在分配在堆空间中?
因为一旦分配在堆空间中,当方法调用结束,没有了引用指向该对象,该对象就需要被gc回收,而如果存在大量的这种情况,对gc来说无疑是一种负担。
因此,针对那些作用域不会逃逸出方法的对象,JVM在分配内存时不在将对象分配在堆内存中,而是将对象属性打散后分配在栈上,这样,随着方法的调用结束,栈空间的回收就会将这些对象回收掉,不再给gc增加负担,从而提升性能。
-
TLAB:全称叫做:Thread Local Allocation Buffer 即线程本地分配缓存
我们知道,对象分配在堆上,而堆是一个全局共享的区域,当多个线程同一时刻操作堆内存分配对象空间时,就需要进行同步,而同步带来的效果就是对象分配效率变差(尽管JVM采用了CAS的形式处理分配失败的情况),但对于存在竞争激烈的分配场合仍然会导致效率变差。
TLAB就是JVM构造了一小块线程私有的堆空间,(实际上是Eden区中划出的),每个线程在分配对象到堆空间时,先分配到自己的TLAB中,避免同步带来的效率问题,从而提高分配效率
对象分配流程图
内存分配并发问题(需要掌握)
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象十分频繁,作为虚拟机来说,必须要保证线程安全。通常来讲,虚拟机采用两种方式来保证:
-
TLAB
-
CAS + 失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
Step3:初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证对象的实例字段在 Java 代码中可以不赋初值直接使用。
Step4:设置对象头
初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
Step5:执行 init 方法
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生,但从 Java 程序的视角来看,对象创建才刚开始,init方法还没有执行。所以一般来说,执行 new 指令之后会接着执行方法,把对象按照程序员的意愿进行初始化。
3.2 对象的内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充
-
Hotspot 虚拟机的对象头包括两部分信息
-
第一部分用于存储对象的运行时数据(哈希码、GC 分代年龄、锁状态标志等等)
-
另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
-
-
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
-
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
4 对象的访问定位有哪两种方式(句柄和直接指针)
建立对象就是为了使用对象,我们的 Java 程序通过栈上的引用reference 来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有①使用句柄和②直接指针两种:
-
使用句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;
-
直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。
这两种对象访问方式各有优势。
-
使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
-
使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
5 String类和常量池(补充,有个印象)
5.1 String 对象的两种创建方式:
String str1 = "abcd";//先检查字符串常量池中有没有"abcd",如果字符串常量池中没有,则创建一个,然后 str1 指向字符串常量池中的对象,如果有,则直接将 str1 指向"abcd"";
String str2 = new String("abcd");//堆中创建一个新的对象
String str3 = new String("abcd");//堆中创建一个新的对象
System.out.println(str1==str2);//false
System.out.println(str2==str3);//false
这两种不同的创建方法是有差别的。
-
双引号:是在常量池中拿对象;
-
new方法:是直接在堆内存空间创建一个新的对象。
记住一点:只要使用 new 方法,便需要创建新的对象。
String 类型的常量池比较特殊。它的主要使用方法有两种:
-
直接使用双引号声明出来的 String 对象会直接存储在常量池中。
-
如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern 方法。
String.intern() 是一个 Native 方法,它的作用是:
如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,JDK1.7前是在常量池中创建与此 String 内容相同的字符串,并返回对它的引用;JDK1.7及以后是在常量池中记录此字符串的引用,并返回该引用。
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 对象
String str1 = "str";
String str2 = "ing";
String str3 = "str" + "ing";//常量池中的对象
String str4 = str1 + str2; //在堆上创建的新的对象
String str5 = "string";//常量池中的对象
System.out.println(str3 == str4);//false
System.out.println(str3 == str5);//true
System.out.println(str4 == str5);//false
5.2 String s1 = new String("abc");这句话创建了几个字符串对象?
将创建 1 或 2 个字符串。
如果池中已存在字符串常量“abc”,则只会在堆空间创建一个字符串常量“abc”。
如果池中没有字符串常量“abc”,那么首先在池中创建,然后在堆空间中创建,因此将创建 2 个。
6 Java会存在内存泄漏吗?请简单描述
内存泄漏是指不再被使用的对象或者变量一直被占据在内存中。理论上来说,Java是有GC垃圾回收机制的,也就是说,不再被使用的对象,会被GC自动回收掉,自动从内存中清除。
但是,即使这样,Java也还是存在着内存泄漏的情况,java导致内存泄露的原因很明确:长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是java中内存泄露的发生场景。
JVM垃圾回收
Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。
同时,Java 自动内存管理最核心的功能————是堆内存中对象的分配与回收。
Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或更快地分配内存。
简述Java垃圾回收机制
在java中,程序员不需要显示的去释放一个对象的内存,虚拟机会自行执行。
在JVM中,有一个垃圾回收线程,它是低优先级的,正常情况下不会执行,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫描那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收。
GC是什么?为什么要GC
GC 是垃圾收集的意思(Gabage Collection),内存处理是编程人员容易出现问题的地方,忘记或者错误的内存回收会导致程序或系统的不稳定甚至崩溃,
java 提供的 GC 功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的,而且Java 语言没有提供释放已分配内存的显式操作方法。
说一下堆内存分配对象的基本策略
堆空间的基本结构:
大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,进入 s0 或者 s1,并且对象年龄加 1,当它的年龄增加到一定程度,就晋升到老年代。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置。
另外,大对象和长期存活的对象直接进入老年代。
所谓大对象是指需要大量连续内存空间的对象(比如:字符串、数组),频繁出现大对象是致命的,会导致在内存还有不少空间的情况下提前触发 GC 以获取足够的连续空间来安置新对象。
前面介绍过新生代使用的是复制算法来处理垃圾回收的,如果大对象直接在新生代分配就会导致 Eden 区和两个 Survivor 区之间发生大量的内存复制。因此对于大对象都会直接在老年代进行分配。
简述分代垃圾回收器是怎么工作的?
分代回收器有两个分区:老生代和新生代,新生代默认的空间占比总空间的 1/3,老生代的默认占比是 2/3。
新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1,它的执行流程如下:
-
把 Eden + From Survivor 存活的对象放入 To Survivor 区;
-
清空 Eden 和 From Survivor 分区;
-
From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。
每次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老生代。大对象也会直接进入老生代。
老生代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。
Minor Gc 和 Full GC 有什么不同?(GC的分类)
针对HotSpot虚拟机的实现,它里面的GC其实准确分类只有两大种:
部分收集 (Partial GC):
-
新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
-
老年代收集(Old GC):只对老年代进行垃圾收集。
-
混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
整堆收集 (Full GC):收集整个 Java 堆和方法区。
Major GC 这个词不准确,它可以指代Old GC,有时候也指Full GC
如何判断对象是否死亡?
堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。
引用计数器
给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的,这个方法实现简单,效率高。
缺点:很难解决对象之间相互循环引用的问题。比如两个对象相互引用,但除此之外再无外部引用,引用计数算法就无法通知 GC 回收器回收他们。
可达性分析算法
基本思想就是用 “GC Roots” 作为起点,向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。
可作为GC Roots的对象包括下面几种:
-
虚拟机栈(栈帧中的本地变量表)中引用的对象
-
本地方法栈(Native方法)中引用的对象
-
方法区中类静态属性引用的对象
-
方法区中常量引用的对象
介绍一下强引用、软引用、弱引用、虚引用
无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。
引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)
1.强引用(StrongReference)
如果一个对象具有强引用(最普遍),就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠回收具有强引用的对象来解决内存不足问题。
2.软引用(SoftReference)
如果一个对象只具有软引用,就类似于可有可无的生活用品。如果内存空间不足才会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。
软引用可用来实现内存敏感的高速缓存。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就把这个软引用加入到与之关联的引用队列中。
3.弱引用(WeakReference)
如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
4.虚引用(PhantomReference)
"虚引用"顾名思义,就是形同虚设,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
虚引用主要用来跟踪对象被垃圾回收的活动。
虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列联合使用。
当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出等问题的产生。
如何判断一个常量是废弃常量
运行时常量池主要回收的是废弃的常量。那么,我们如何判断一个常量是废弃常量呢?
假如在常量池中存在字符串 "abc",如果当前没有任何 String 对象引用它,就说明它就是废弃常量,如果这时发生内存回收且有必要的话,"abc" 就会被系统清理出常量池。
如何判断一个类是无用的类
方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?
判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 无用的类 :
-
该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
-
加载该类的 ClassLoader 已经被回收。
-
该类对应的Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。
垃圾收集有哪些算法,各自的特点?
1 标记-清除算法
“标记”阶段:首先标记出所有不需要回收的对象
“清除”阶段:在标记完成后统一回收掉所有没有被标记的对象。
它是最基础的收集算法,后续的算法都是对其不足进行改进。它会带来两个明显的问题:
-
效率问题
-
空间问题(标记清除后会产生大量不连续的碎片)
2 复制算法
为了解决效率问题,“复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,将还存活的对象顺序复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
3 标记-整理算法
根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
4 分代收集算法
当前虚拟机的垃圾收集都采用分代收集算法,这种算法根据对象存活周期的不同将内存分为几块
一般将 java 堆分为新生代和老年代,这样就可以根据各个年代的特点选择合适的垃圾收集算法。
比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
而老年代的对象存活几率比较高,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
HotSpot 为什么要分为新生代和老年代(延伸)?
主要为了提升垃圾收集效率,根据上面的对分代收集算法的介绍回答。
常见的垃圾回收器有哪些?
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
下图展示了7种作用于不同分代的收集器,其中:
-
新生代回收器:Serial、ParNew、Parallel Scavenge
-
老年代回收器:Serial Old、Parallel Old、CMS
-
整堆回收器:G1
新生代垃圾回收器一般采用的是复制算法,复制算法的优点是效率高,缺点是内存利用率低;
老年代回收器一般采用的是标记-整理的算法进行垃圾回收。
不同收集器之间的连线表示它们可以搭配使用。
-
Serial收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效;
-
ParNew收集器 (复制算法): 新生代并行收集器,实际上是Serial收集器的多线程版本
-
Parallel Scavenge收集器 (复制算法): 新生代并行收集器,追求高吞吐量,高效利用 CPU。
吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,适合对交互要求不高的场景;
-
Serial Old收集器 (标记-整理算法): 老年代单线程收集器,Serial收集器的老年代版本;
-
Parallel Old收集器 (标记-整理算法): 老年代并行收集器,吞吐量优先,Parallel Scavenge的老年代版本
-
CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间,适用于要求服务器响应速度的应用。
-
G1(Garbage First)收集器 (标记-整理算法): Java堆并行收集器
G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代
详细介绍一下 CMS,G1 收集器。
CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用,也就是要求服务器响应速度的应用上使用。
CMS是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
从名字中的Mark Sweep这两个词可以看出,CMS 收集器是 “标记-清除”算法实现的,它的运作过程比较复杂,分为四个步骤:
-
初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
-
并发标记: 同时开启 GC 和用户线程,用一个闭包结构记录可达对象,跟踪记录发生引用更新的地方。
-
重新标记: 修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间长一点,比并发标记阶段时间短很多
-
并发清理: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
主要优点:并发收集、低停顿。
三个明显缺点:
-
它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。
-
对 CPU 资源敏感;
-
无法处理浮动垃圾;
并发清理阶段用户线程还在运行,这段时间就可能产生新的垃圾,新的垃圾在此次GC无法清除,只能等到下次清理,这些垃圾就叫做浮动垃圾。
G1 收集器
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多处理器及大容量内存的机器. 以极高概率,满足 GC 停顿时间要求,还同时具备高吞吐量
特点:
-
并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU来缩短停顿时间。
部分其他收集器需要停顿 Java 线程才能执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
-
分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但还是保留了分代的概念。
-
空间整合:G1 从整体来看基于“标记-整理”算法
-
可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。
G1 收集器的运作步骤:
-
初始标记
-
并发标记
-
最终标记
-
筛选回收
G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 区域Region(这也就是它的名字 Garbage-First 的由来)。这种使用 Region 划分内存空间,以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内尽可能高的收集效率(把内存化整为零)。
JVM类加载机制
类的生命周期
一个类的完整生命周期如下:
类加载过程
Class 文件需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些 Class 文件呢?
系统加载 Class 类型的文件主要三步:加载->连接->初始化。
连接过程又可分为三步:验证->准备->解析。
简述java类加载机制?
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,准备,解析和初始化,最终形成可以被虚拟机直接使用的java类型。
说一下类装载的执行过程?
类装载分为以下 5 个步骤:
-
加载:根据查找路径找到相应的 class 文件然后导入;
-
验证:检查加载的 class 文件的正确性;
-
准备:给类中的静态变量分配内存空间;
-
解析:虚拟机将常量池中的符号引用替换成直接引用的过程。符号引用就理解为一个标识,在直接引用直接指向内存中的地址;
-
初始化:对静态变量和静态代码块执行初始化工作。
描述一下JVM加载Class文件这步做了什么(原理)?
类加载过程的第一步,主要完成下面3件事情:
-
通过全类名获取定义此类的二进制字节流
-
将字节流所代表的静态存储结构转换为方法区的运行时数据结构
-
在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口
Java中的所有类,都需要由类加载器装载到JVM中才能运行。类加载器本身也是一个类,它的工作就是把class文件从硬盘读取到内存中。在写程序的时候,我们几乎不需要关心类的加载,因为这些都是隐式装载的,除非我们有特殊的用法,像反射,就需要显式的加载所需要的类。
类有两种装载方式:
-
隐式装载, 程序在运行过程中当碰到通过new 等方式生成对象时,隐式调用类装加器加载对应的类到jvm中,
-
显式装载, 通过class.forname()等方法,显式加载需要的类
Java类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(像是基类)完全加载到jvm中,至于其他类,则在需要的时候才加载,这是为了节省内存开销。
什么是类加载器,类加载器有哪些?
实现通过类的权限定名获取该类的二进制字节流的类叫做类加载器。
JVM 中内置了三个重要的 类加载器,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader
:
-
BootstrapClassLoader(启动类加载器) :最顶层的加载类,由C++实现,负责加载
%JAVA_HOME%/lib
目录下的jar包和类或者或被-Xbootclasspath
参数指定的路径中的所有类。 -
ExtensionClassLoader(扩展类加载器) :主要负责加载目录
%JRE_HOME%/lib/ext
目录下的jar包和类,或被java.ext.dirs
系统变量所指定的路径下的jar包。 -
AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用classpath下的所有jar包和类。
介绍一下双亲委派模型?
每一个类都有一个对应它的类加载器。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。
加载的时候,首先会把该请求委派该父类加载器的 loadClass()
处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader
中。
当父类加载器无法处理时,才往下交给子类加载器来处理。当父类加载器为null时,会使用启动类加载器 BootstrapClassLoader
作为父类加载器。
双亲委派模型的好处
-
保证了Java程序的稳定运行
-
可以避免类的重复加载
JVM 区分不同类的方式不仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类
-
保证了 Java 的核心 API 不被篡改。
如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为
java.lang.Object
类的话,那么程序运行的时候,系统就会出现多个不同的Object
类。
如果不想用双亲委派模型怎么办?
需要自定义加载器:继承 ClassLoader
。
如果我们不想打破双亲委派模型,就重写 ClassLoader
类中的 findClass()
方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。
loadClass(String): loadClass()方法是ClassLoader类自己实现的,该方法中的逻辑就是双亲委派模式的实现
findClass(): 是在loadClass()方法中被调用的,当loadClass()方法中父加载器加载失败后,则会调用自己的findClass()方法来完成类加载,这样就可以保证自定义的类加载器也符合双亲委托模式
JVM调优(这部分需要实践,之后用到的时候去查)
说一下 JVM 调优的工具?
JDK 自带了很多监控工具,都位于 JDK 的 bin 目录下,其中最常用的是 jconsole 和 jvisualvm 这两款视图监控工具。
-
jconsole:用于对 JVM 中的内存、线程和类等进行监控;
-
jvisualvm:JDK 自带的全能分析工具,可以分析:内存快照、线程快照、程序死锁、监控内存的变化、gc 变化等。
常用的 JVM 调优的参数都有哪些?
-
-Xms2g:初始化推大小为 2g;
-
-Xmx2g:堆最大内存为 2g;
-
-XX:NewRatio=4:设置年轻的和老年代的内存比例为 1:4;
-
-XX:SurvivorRatio=8:设置新生代 Eden 和 Survivor 比例为 8:2;
-
–XX:+UseParNewGC:指定使用 ParNew + Serial Old 垃圾回收器组合;
-
-XX:+UseParallelOldGC:指定使用 ParNew + ParNew Old 垃圾回收器组合;
-
-XX:+UseConcMarkSweepGC:指定使用 CMS + Serial Old 垃圾回收器组合;
-
-XX:+PrintGC:开启打印 gc 信息;
-
-XX:+PrintGCDetails:打印 gc 详细信息。