Java笔记 | JVM
- 调用垃圾回收器的方法
调用垃圾回收器的方法是 gc,该方法在 System 类和 Runtime 类中都存在。
在 Runtime 类中,方法 gc 是实例方法,方法 System.gc 是调用该方法的一种传统而便捷的方法。
在 System 类中,方法 gc 是静态方法,该方法会调用 Runtime 类中的 gc 方法。
其实,java.lang.System.gc 等价于 java.lang.Runtime.getRuntime.gc 的简写,都是调用垃圾回收器。
方法 gc 的作用是提示 Java 虚拟机进行垃圾回收,该方法由系统自动调用,不需要人为调用。该方法被调用之后,由 Java 虚拟机决定是立即回收还是延迟回收。
- 运行时数据区域
堆
--线程共享
--作用:存放对象实例。
--垃圾回收的主要区域。
--最大的一块。
方法区
--线程共享
--作用:存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
--运行时常量池是方法区的一部分。
--JDK 1.8 将方法区彻底移除,取而代之的是元空间,元空间使用的是直接内存。
虚拟机栈
--线程私有
--虚拟机栈描述的是 Java 方法执行的内存模型,每个方法被执行的时候会创建一个栈帧,用于存储局部变量表、操作栈、动态链接、方法出口等信息。一个方法被调用直至执行完成的过程对应一个栈帧在虚拟机中从入栈到出栈的过程。
--局部变量表存放编译器可知的各种基本数据类型、对象引用类型和返回地址类型。
--如果虚拟机栈不可以动态扩展,当线程请求的栈深度大于虚拟机所允许的深度时,将抛出 StackOverflowError 异常。
--如果虚拟机栈可以动态扩展,当无法申请到足够的内存时,将抛出 OutOfMemoryError 异常。
本地方法栈
--服务于本地方法的栈。
程序计数器
--是当前线程所执行的字节码的行号指示器,通过改变程序计数器的值选取下一条需要执行的字节码指令。
--唯一不会出现 OutOfMemoryError 的内存区域。
- 判断对象是否可回收的方法
引用计数法:引用计数算法给每个对象添加引用计数器,用于记录对象被引用的计数,引用计数为 0 的对象即为可回收的对象。如果多个对象之间存在循环引用,则这些对象的引用计数永远不为 0,无法被回收。
根搜索法(可达性分析法):从若干被称为 GC Roots 的对象开始进行搜索,不能到达的对象即为可回收的对象。主流的商用程序语言都是使用根搜索算法判断对象是否可回收。
- 引用的分类
强引用:在程序代码中普遍存在的引用。垃圾回收器永远不会回收被强引用关联的对象。
软引用:在系统将要发生内存溢出异常时,被软引用关联的对象才会被回收。
弱引用:被弱引用关联的对象只能存活到下一次垃圾回收发生之前,当垃圾回收器工作时,被弱引用关联的对象一定会被回收。
虚引用:无法通过虚引用取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个系统通知。
- 垃圾回收策略
标记清除法:最基础的垃圾回收算法,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
--缺点:一是效率问题,标记和清除的效率都不高;二是空间问题,标记清除之后会产生大量不连续的内存碎片,导致程序在之后的运行过程中无法为较大对象找到足够的连续内存。
标记复制法:将可用内存分成大小相等的两块,每次只使用其中的一块,当用完一块内存时,将还存活着的对象复制到另外一块内存,然后把已使用过的内存空间一次清理掉。
--优点:实现简单,运行高效。
--缺点:将内存缩小为了原来的一半;在对象存活率较高时复制操作的次数较多,导致效率降低。
标记整理法:在标记后,让所有存活的对象都向一端移动,然后清除边界以外的内存。是根据老年代的特点提出的。
分代收集法:根据对象的存活周期不同将内存划分为多个区域,对每个区域选用不同的垃圾回收算法。新生代选用复制算法,老生代选用标记清除法或标记整理法。
- 内存分配策略
Java 堆可以分成新生代和老年代,新生代又可以细分成 Eden 区、From Survivor 区、To Survivor 区等。
Minor GC 指发生在新生代的垃圾回收操作。因为大多数对象的生命周期都很短,因此 Minor GC 会频繁执行,一般回收速度也比较快。Minor GC同样会检查存活下来的对象,并把它们转移到另⼀个survivor区。这样在⼀段时间内,总会有⼀个空的survivor区。
Full GC(也称 Major GC),指发生在老年代的垃圾回收操作。出现了 Full GC,经常会伴随至少一次的 Minor GC。老年代对象的存活时间长,因此 Full GC 很少执行,而且执行速度会比 Minor GC 慢很多。
内存分配规则:
新生代:
--对象优先在 Eden 区分配。大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。把所有存活下来的对象转移到其中⼀个survivor区。
老年代:
--大对象直接进入老年代。大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。将大对象直接在老年代中分配的目的是避免在 Eden 区和 Survivor 区之间出现大量内存复制。
--长期存活的对象进入老年代。虚拟机采用分代收集的思想管理内存,因此需要识别每个对象应该放在新生代还是老年代。虚拟机给每个对象定义了年龄计数器,对象在 Eden 区出生之后,如果经过第一次 Minor GC 之后仍然存活,将进入 Survivor 区,同时对象年龄变为 1,对象在 Survivor 区每经过一次 Minor GC 且存活,年龄就增加 1,增加到一定阈值时则进入老年代(阈值默认为 15)。
--动态对象年龄判定。为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到阈值才能进入老年代。如果在 Survivor 区中相同年龄的所有对象的空间总和大于区空间的一半,则年龄大于或等于该年龄的对象直接进入老年代。
--空间分配担保。如果 Survivor 空间无法容纳新生代中 Minor GC 之后还存活的对象,则进入老年代。在发生 Minor GC 之前,如果老年代最大可用的连续空间大于新生代所有对象的空间总和,就会进行 Minor GC,否则将进行 Full GC。
Minor GC 条件:
--当Eden区空间不足以继续分配对象,发起Minor GC。
Full GC 条件:
--调用System.gc时,系统建议执行Full GC,但是不必然执行。
--老年代空间不足(通过Minor GC后进入老年代的大小大于老年代的可用内存)。
--方法区空间不足。
- finalize
方法 finalize 在 Object 类中被定义,该方法的默认实现不做任何事。在释放对象占用的内存之前会调用该方法,如果必要,子类应该重写该方法,一般建议在该方法中释放对象持有的资源。
- 类加载过程
类的生命周期:加载、验证、准备、解析、初始化、使用、卸载。
加载:获取定义此类的二进制字节流,将字节流的静态存储结构转化为方法区的运行时数据结构,在Java堆中生成一个代表该类的java.lang.Class对象,作为这些数据在方法区的访问入口。
验证:文件格式验证、无数据验证、字节码验证、符号引用验证。验证字节流信息符合当前虚拟机的要求。
准备:为类变量分配内存,设置类变量初始值。
解析:将常量池的符号引用替换为直接引用,包括类或接口的解析、字段解析、类方法解析、接口方法解析。符号引用是用一组符号来描述所引用的目标,直接引用是指向目标的指针
初始化:根据程序初始化类变量和其他资源。执行类构造器、类变量赋值、静态语句块。
- 类加载器
启动类加载器 (Bootstrap ClassLoader):负责加载 JAVA_HOME\lib 目录中的,或通过 - Xbootclasspath 参数指定路径中的,且被虚拟机认可(按文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录也不会被加载)的类。启动类加载器无法被 Java 程序直接引用。
扩展类加载器 (Extension ClassLoader):负责加载 JAVA_HOME\jre\lib\ext 目录中的,或通过 java.ext.dirs 系统变量指定路径中的类库。开发者可以直接使用。
应用程序类加载器 (Application ClassLoader):负责加载用户路径(classpath)上的类库。开发者可以直接使用。
除此之外,还可以通过继承 java.lang.ClassLoader 类实现自己的类加载器(主要是重写 findClass 方法)。
- 类加载机制:双亲委派机制
定义:
当某个类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,如果父类加载器可以完成类加载任务,就成功返回,如果父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
优点:
--避免类的重复加载。越基础的类由越上层的加载器进行加载,如类 java.lang.String,无论哪一个类加载器要加载这个类,最终都是委派给启动类加载器进行加载,所以在程序的各种类加载器环境中都是同一个类。
--提高Java代码安全性。比如说用户自定义了一个与系统库里同名的 java.lang.String 类,那么这个类就不会被加载,因为最顶层的类加载器会首先加载系统的 java.lang.String 类,而不会加载自定义的 String 类,防止了恶意代码的注入。
打破:
--如果重写 ClassLoader 的 loadClass() ⽅法,相当于打破了双亲委派模型。为了让⽤户⾃定义的类加载器也遵从双亲委派模型, JDK新增了 findClass ⽅法,⽤于实现⾃定义的类加载逻辑。
--为了让上层类加载器加载的类能够访问下层类加载器加载的类,或者说让⽗类类加载器委托⼦类类加载器完成加载请求,JDK 引⼊了线程上下⽂类加载器( Thread Context ClassLoader ),由它来打破双亲委派模型的屏障。
--当⽤户需要程序的动态性,⽐如代码热替换、模块热部署等时,双亲委派模型就不再适⽤,类加载器会发展为更为复杂的⽹状结构。
- 创建一个Java对象的过程
--类加载检查:虚拟机遇到㇐条 new 指令时,⾸先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程。
--分配内存:虚拟机将为新⽣对象分配内存。对象所需的内存⼤⼩在类加载完成后便可确定,为对象分配空间的任务等同于把㇐块确定⼤⼩的内存从 Java 堆中划分出来。分配⽅式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配⽅式由 Java 堆是否规整决定,⽽Java堆是否规整⼜由所采⽤的垃圾收集器是否带有压缩整理功能决定。
--初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值,这㇐步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值。
--设置对象头:初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会
有不同的设置⽅式。
--执行init方法:在上⾯⼯作都完成之后,从虚拟机的视⻆来看,㇐个新的对象已经产⽣了,但从Java 程序的视⻆来看,对象创建才刚开始, <init> ⽅法还没有执⾏,所有的字段都还为零。所以㇐般来说,执⾏ new 指令之后会接着执⾏ <init> ⽅法,把对象按照程序员的意愿进⾏初始化,这样㇐个真正可⽤的对象才算完全产⽣出来。
- 垃圾收集器
串行收集器 Serial
串行收集器 Serial 是最古老的收集器。ParNew 垃圾收集器是 Serial 收集器的多线程版本。
特点:只使用一个线程去回收,可能会产生较长的停顿。
新生代使用 Serial 收集器复制算法、老年代使用 Serial Old 标记-整理算法。
参数:-XX:+UseSerialGC,默认开启-XX:+UseSerialOldGC
并行收集器 Parallel
并行收集器 Parallel 关注可控的吞吐量,是 1.8 的 Server 模式的默认收集器,使用多线程收集。
特点:能精确地控制吞吐量与最大停顿时间。
新生代使用复制算法、老年代使用标记-整理算法。
参数:-XX:+UseParallelGC,默认开启-XX:+UseParallelOldGC
并发收集器 CMS
并发收集器 CMS 是以最短停顿时间为目标的收集器。G1关注能在大内存的前提下精确控制停顿时间且垃圾回收效率高。
CMS 针对老年代,有初始标记、并发标记、重新标记、并发清除四个过程,标记阶段会 Stop The World,使用标记-清除算法,所以会产生内存碎片。
参数:-XX:+UseConcMarkSweepGC,默认开启-XX:+UseParNewGC
G1 将堆划分为多个大小固定的独立区域,根据每次允许的收集时间优先回收垃圾最多的区域,使用标记-整理算法,是 1.9 的 Server 模式的默认收集器。
参数:-XX:+UseG1GC
Stop the world
简称 STW,是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互。
STW总会发生,不管是新生代还是老年代,比如CMS在初始标记和重复标记阶段会停顿,G1在初始标记阶段也会停顿,所以并不是选择了一款停顿时间低的垃圾收集器就可以避免STW的,我们只能尽量去减少STW的时间。
为什么一定要STW?因为在定位堆中的对象时JVM会记录下对所有对象的引用,如果在定位对象过程中,有新的对象被分配或者刚记录下的对象突然变得无法访问,就会导致一些问题,比如部分对象无法被回收,更严重的是如果GC期间分配的一个GC Root对象引用了准备被回收的对象,那么该对象就会被错误地回收。
- Java 内存模型
定义:JMM(Java Memory Model)是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题,保证并发编程场景中的原子性、可见性和有序性。
实现:volatile、synchronized、final、concurrent包等。
主内存:所有变量都保存在主内存中。
工作内存:每个线程的独立内存,保存了该线程使用到的变量的主内存副本拷贝,线程对变量的操作必须在工作内存中进行。
每个线程都有自己的本地内存共享副本,如果本地线程要更新主内存还要让另一线程获取更新后的变量,那么需要:
--在本地内存中更新共享变量。
--将更新的共享变量刷新到主内存中。
--另一线程从主内存更新最新的共享变量。
本身随着CPU和内存的发展速度差异的问题,导致CPU的速度远快于内存,所以现在的CPU加入了高速缓存,高速缓存一般可以分为L1、L2、L3三级缓存。基于上面的例子我们知道了这导致了缓存一致性的问题,所以加入了缓存一致性协议,同时导致了内存可见性的问题,而编译器和CPU的重排序导致了原子性和有序性的问题。JMM内存模型正是对多线程操作下的一系列规范约束,因为不可能让代码去兼容所有的CPU,通过JMM我们才屏蔽了不同硬件和操作系统内存的访问差异,这样保证了Java程序在不同的平台下达到一致的内存访问效果,同时也是保证在高效并发的时候程序能够正确执行。
原子性:Java内存模型通过read、load、assign、use、store、write来保证原子性操作,此外还有lock和unlock,直接对应着synchronized关键字的monitorenter和monitorexit字节码指令。
可见性:可见性的问题在上面的回答已经说过,Java保证可见性可以认为通过volatile、synchronized、final来实现。
有序性:由于处理器和编译器的重排序导致的有序性问题,Java通过volatile、synchronized来保证。
- happens-before
我们无法就所有场景来规定某个线程修改的变量何时对其他线程可见,但是我们可以指定某些规则,这规则就是happens-before。特别关注在多线程之间的内存可见性。
happens-before是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。
虽然指令重排提高了并发的性能,但是Java虚拟机会对指令重排做出一些规则限制,并不能让所有的指令都随意的改变执行位置,主要有以下几点:
--单线程每个操作,happen-before于该线程中任意后续操作
--volatile写happen-before与后续对这个变量的读
--synchronized解锁happen-before后续对这个锁的加锁
--final变量的写happen-before于final域对象的读,happen-before后续对final变量的读
--传递性规则,A先于B,B先于C,那么A一定先于C发生
- JVM调优
前提:在进行GC优化之前,需要确认项目的架构和代码等已经没有优化空间。
目的:优化JVM垃圾收集性能从而增大吞吐量或减少停顿时间,让应用在某个业务场景上发挥最大的价值。吞吐量是指应用程序线程用时占程序总用时的比例。暂停时间是应用程序线程让与GC线程执行而完全暂停的时间段。
对于交互性web应用来说,一般都是减少停顿时间,所以有以下方法:
--如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。
--让大对象进入年老代。可以使用参数-XX:PetenureSizeThreshold 设置大对象直接进入年老代的阈值。当对象的大小超过这个值时,将直接在年老代分配。
--设置对象进入年老代的年龄。如果对象每经过一次 GC 依然存活,则年龄再加 1。当对象年龄达到阈值时,就移入年老代,成为老年对象。
--使用关注系统停顿的 CMS 回收器。