JVM
介绍下 Java 内存区域(运行时数据区)?
jdk1.8之前
jdk1.8之后
线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共享的:
- 堆
- 方法区
- 直接内存 (非运行时数据区的一部分)
堆
堆是Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建,用于存放创建的对象实例。从垃圾回收的角度,Java 堆还可以细分为:新生代和老年代,进一步划分的目的是更好地回收内存,或者更快地分配内存。
方法区
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
方法区也被称为永久代,但有所不同,方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。
参数:
jdk1.8前
-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen
jdk1.8后
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小
为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
永久代设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机内存限制,虽然元空间还是有可能溢出,但是比原来出现的几率会更小。
运行时常量
运行时常量是方法区的一部分,用于存放编译期生成的各种字面量和符号引用。
直接内存
在JAVA堆外的,直接向系统内存申请的内存区间。
虚拟机栈
Java 虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型,每次方法调用的数据都是通过栈传递的。Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。
Java 虚拟机栈会出现两种错误:StackOverFlowError和 OutOfMemoryError。
- StackOverFlowError: 若 Java 虚拟机栈内存不允许动态扩展,当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
- OutOfMemoryError: Java 虚拟机栈内存大小可以动态扩展, 虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常异常。
本地方法栈
本地方法栈跟虚拟机栈的作用相似,区别是虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。,本地方法栈也有栈帧,也会抛出StackOverFlowError和OutOfMemoryError异常。
程序计数器
程序计数器是一块较小的内存空间,字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制(顺序执行、选择、循环、异常处理);在多线程的情况下,程序计数器用于记录当前线程执行的位置,,为了线程切换后能恢复到正确的执行位置。
Java 对象的创建过程
(五步,建议能默写出来并且要知道每一步虚拟机做了什么)
1.类加载检查
虚拟机收到一条 new 指令的时候,就去检查是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那就先执行相应的类加载过程。
2.分配内存
虚拟机将为新生对象分配内存,分配方式有 “指针碰撞” 和 “空闲列表”两种,选择哪种分配方式由 Java 堆是否规整决定,Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),
内存分配并发问题,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- TLAB:JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
3.初始化零值
虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
4.设置对象头
虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、怎么找到类的数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。
5.执行 init 方法
把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
对象的内存布局
对象的内存布局可以分为 3 块区域:对象头、实例数据和对齐填充。
对象头:对象头包括两部分信息,一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针。
示例数据:实例数据部分是对象真正存储的有效信息
对齐填充:对齐填充仅仅起占位作用。( 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。)
对象的访问定位
目前主流的访问方式有①使用句柄和②直接指针两种
使用句柄来访问的好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式的好处就是速度快,节省了一次指针定位的时间。
面试题
4.2 String s1 = new String(“abc”);这句话创建了几个字符串对象?
将创建 1 或 2 个字符串。如果池中已存在字符串常量“abc”,则只会在堆空间创建一个字符串常量“abc”。如果池中没有字符串常量“abc”,那么它将首先在池中创建,然后在堆空间中创建,因此将创建总共 2 个字符串对象。
new String(“a”)+new String(“b”)创建几个对象?
6个:
-
new String(“a”)
-
new String(“b”)
-
字符串常量池"a"
-
字符串常量池"b"
-
new StringBuilder();
-
还包含 StringBuilder的toString()方法
如何判断对象是否死亡(两种方法)
引用计数法:给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;计数器为 0 的对象就是不可能再被使用的。
缺点:很难解决对象之间相互循环引用的问题。 除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。
**可达性分析算法:**以一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。
可作为 GC Roots 的对象包括下面几种:
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象
- 方法区中引用的对象等
简单的介绍一下强引用、软引用、弱引用、虚引用(虚引用与软引用和弱引用的区别、使用软引用能带来的好处)
强引用 —— 不回收
最常见的引用,即使内存空间不足导致OOM(OutOfMemoryError ),也不会回收可触及的强引用,强引用是造成Java内存泄漏的主要原因。
软引用 —— 内存不足回收
软引用用来描述还有用,但不是必需的对象。 如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。
弱引用 ——发现即回收
弱引用也是用来描述那些非必需对象。只要发现弱引用,不管堆空间使用是否充足,都会回收掉只被弱引用关联的对象。
但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较 长的时间。
弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可,以跟踪对象的回收情况。
虚引用 —— 对象回收跟踪
如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。虚引用主要用来跟踪对象被垃圾回收的活动,程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。
虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
软引用的好处:在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。(当系统内存不足时,这些缓存数据会被回收,不会导致内存溢 出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用 )
如何判断一个常量是废弃常量
在字常量池中存在常量,如果当前没有任何对象引用该常量的话,就说明常量就是废弃常量。
如何判断一个类是无用的类
类需要同时满足下面 3 个条件才能算是 “无用的类” :
-
该类所有的实例都已经被回收
-
加载该类的
ClassLoader
已经被回收。 -
该类对应的
java.lang.Class
对象没有在任何地方被引用
垃圾收集有哪些算法,各自的特点?
标记-复制算法
将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
标记-清除算法
首先标记出所有不需要回收的对象,然后回收掉所有没有被标记的对象。
缺点:效率问题;空间问题(标记清除后会产生大量不连续的垃圾碎片)
标记-整理算法
首先标记出所有不需要回收的对象,让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
分代收集算法
当前虚拟机的垃圾收集都采用分代收集算法,根据对象存活周期的不同将 java 堆分为新生代和老年代。
在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
延伸面试问题: HotSpot 为什么要分为新生代和老年代?
根据上面的对分代收集算法的介绍回答。
类加载的过程
把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型的
过程。
步骤:加载 --> 链接 (验证,准备,解析) --> 初始化
加载
- 通过全类名获取定义此类的二进制字节流
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口
验证
文件格式验证,元数据验证,字节码验证,符号引用验证
准备
为类变量分配内存并设置类变量初始值,这些内存都将在方法区中分配。(对象实例在堆中)
解析
虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。
初始化
编译器会收集代码中的静态变量的赋值动作和静态代码块,生成一个方法,然后执行。
常见的垃圾回收器有哪些?
Serial收集器:串行回收
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器。是一个单线程收集器了。它的单线程不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程,直到它收集结束。
新生代采用标记-复制算法,老年代采用标记-整理算法。
优点:简单而高效。没有线程交互的开销,可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。
缺点:单线程慢,带来的不良用户体验
ParNew收集器 :并发回收
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。
除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。
新生代采用标记-复制算法,老年代采用标记-整理算法。
Parallel Scavenge 收集器:并发回收,关注吞吐量
Parallel Scavenge 收集器关注点是吞吐量(所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值) ,几乎和 ParNew一样
新生代采用标记-复制算法,老年代采用标记-整理算法。
CMS收集器:并发回收,低停顿
CMS收集器(Concurrent Mark Sweep)是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作,一种以获取最短回收停顿时间为目标的收集器,符合用户体验。
基于 “标记-清除”算法实现的,整个过程分为四个步骤:
- 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
- **并发标记:**从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程并发执行。
- 重新标记: 修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,停顿时间比初始标记阶段稍长,但远低于并发标记阶段的时间。
- 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
优点:并发收集、低停顿。
缺点:
-
对 CPU 资源敏感;
-
无法处理浮动垃圾;
-
使用“标记-清除”算法导致大量空间碎片产生。
G1收集器:区域化分代式
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。
被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备一下特点:
-
并行与并发:并行性:可以有多个GC线程同时工作
并发性:G1可以与应用程序交替执行,部分工作可以同时执行。
-
分代收集:G1 可以不需要其他收集器配合独立管理整个 GC 堆,保留了分代的概念。
-
空间整合:G1 从整体来看是基于“标记-整理”算法实现的收集器,但从局部上来看是基于“标记-复制”算法实现的。
-
可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型。
G1 收集器的运作大致分为以下几个步骤:
-
初始标记
-
并发标记
-
最终标记
-
筛选回收
优点:G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。
缺点:无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都比CMS高。
小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB。
双亲委派模型
每一个类都有一个对应它的类加载器。系统中的 ClassLoder 在协同工作的时候会默认使用 双亲委派模型 。
了解:(其实这个双亲翻译的容易让别人误解,我们一般理解的双亲都是父母,这里的双亲更多地表达的是“父母这一辈”的人而已,并不是说真的有一个 Mother ClassLoader 和一个 Father ClassLoader 。另外,类加载器之间的“父子”关系也不是通过继承来体现的,是由“优先级”来决定。)
优点:双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载
(相同的类文件被不同的类加载器加载产生的是两个不同的类,也保证了 Java 的核心 API 不被篡改。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object
类的话,那么程序运行的时候,系统就会出现多个不同的 Object
类。)
f1n-1616055291600)]
了解:(其实这个双亲翻译的容易让别人误解,我们一般理解的双亲都是父母,这里的双亲更多地表达的是“父母这一辈”的人而已,并不是说真的有一个 Mother ClassLoader 和一个 Father ClassLoader 。另外,类加载器之间的“父子”关系也不是通过继承来体现的,是由“优先级”来决定。)
优点:双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载
(相同的类文件被不同的类加载器加载产生的是两个不同的类,也保证了 Java 的核心 API 不被篡改。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object
类的话,那么程序运行的时候,系统就会出现多个不同的 Object
类。)