JVM基础概念(重要)
理解JVM特点
和C++对比着说
- Java运行在虚拟机上,号称与平台无关。也就是你开发的Java程序无论是Unix,Linux还是Windows都可以正常运行。C++都是直接编译成可执行文件,是否能跨平台主要看你用到的编译器特性是否有多平台支持。
- Java因为是运行在虚拟机上,不需要考虑内存管理和垃圾回收机制。也就是你可以声明一个对象而不用考虑释放它,虚拟机帮你做这些事情。而C和C++语言本身没有多少内存管理的概念,写C和C++程序如果用到指针就一定要考虑内存申请和释放。内存泄漏是C和C++最头疼的问题。
- 因为C和C++是直接编译成可执行文件,所以运行效率要比Java高。但是由于JVM的即时编译技术,Java运行速度也很快。
JVM的优势
- 一次编写,到处运行。
- 托管环境(Managed Runtime),帮助使用者自动做了一些事情,使我们免于书写这些无关业务逻辑的代码。
JVM的内存模型
线程共享
堆
(用来放置 Java 对象实例)
-
常见的指标
- 最大堆体积
-Xmx value - 初始的最小堆体积
-Xms value - 老年代和新生代的比例
-XX:NewRatio=value默认为2:1 - 为新生代设定具体的内存大小数值
-XX:NewSize=value
- 最大堆体积
-
OOM的发生和防止
-
定义:当 JVM 内存不够用了,没有空闲内存,并且垃圾收集器也无法提供更多内存,那么就会触发OOM。
-
发生:
- 堆内存不足,原因是可能存在内存泄漏问题;堆的大小不合理;或者出现 JVM 处理引用不及时,导致堆积起来,内存无法释放等。
- 对于 Java 虚拟机栈和本地方法栈,类似于不断地进行递归且没有推出条件,导致不断的压栈就会抛 StackOverFlowError。如果 JVM 试图去扩展栈空间的的时候失败,则会抛出 OutOfMemoryError。
- 在老版的JDK中,由于永久代大小有限,JVM堆永久代垃圾回收不积极,在不断添加新类型时很容易出现OOM。
- 随着元数据区的引入,方法区内存已经不再窘迫,相应的 OOM 有所改观。
- 直接内存不足,也会导致 OOM
-
解决:对其进行排查。
- 使用虚拟机进程状况工具jps,确定频繁Full GC现象
- 使用jmap,找出导致频繁Full GC的原因
- 使用MAT查看,定位到代码,
-
方法区
(存储所谓的元数据,例如类结构信息,以及对应的运行时常量池、字段、方法代码等。)(由于早期的 Hotspot JVM 实现,很多人习惯于将方法区称为永久代,JDK 8 中永久代被删除,增设元数据区,元数据区默认是自增的,永久代做不到)
- 运行时常量池:存放各种常量信息,不管是编译期生成的各种字面量,还是需要在运行时决定的符号引用,所以它比一般语言的符号表存储的信息更加宽泛。
线程私有
PC寄存器(看计组)
- PC 寄存器(Program Counter Register),也叫指令地址寄存器(Instruction Address Register)。它是用来存放下一条需要执行的计算机指令的内存地址。
Java方法栈:
- 每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的 Java 方法调用。一个程序执行的时候,CPU 会根据 PC 寄存器里的地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后PC寄存器里的地址值根据指令长度自增,顺序读取下一条指令。有些特殊指令,比如跳转指令就会修改PC寄存器里面的地址值,这样下一条要执行的指令就不是内存里面循序加载的了。这也是程序中可以使用if else和while/for循环语句的原因
本地方法栈:
- 支持对本地方法的调用,每个线程都会创建一个。
JVM的解释执行和即时编译器
定义:从硬件视角来看,Java 字节码无法直接执行。因此,Java 虚拟机需要将字节码翻译成机器码。翻译过程有两种形式:解释执行和即时编译
- 解释执行即逐条将字节码翻译成机器码并执行,优势在于无需等待编译
- 即时编译(JIT),即将一个方法中包含的所有字节码编译成机器码后再执行。优势在于实际运行速度比解释执行更快。
HotSpotmore拟采用混合模式,综合了解释执行和即时编译器的优点。她会解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。
- 即时编译器:HotSpot 装在了多个不同的即时编译器:C1、C2 和 Graal,以便在编译时间和生成代码的执行效率之间做取舍。
JVM原理
从 class 文件到内存中的类,按先后顺序需要经过加载、链接以及初始化三大步骤。
Java的类加载过程
加载(重要)
加载,是指查找字节流,并且据此创建类的过程。对于数组类来说,它并没有对应的字节流,而是由 Java 虚拟机直接生成的。对于其他的类来说,Java 虚拟机则需要借助类加载器来完成查找字节流的过程。
类加载器
在 Java 9 之前
- 启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录下 jar 包中的类。除了启动类加载器之外。
- 扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存放在 JRE 的 lib/ext 目录下 jar 包中的类。
- 应用类加载器的父类加载器则是扩展类加载器。它负责加载应用程序路径classpath下的类。
Java 9 之后引入了模块系统
- 扩展类加载器被改名为平台类加载器(platform class loader)。在这之后 Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。
- 除了由 Java 核心类库提供的类加载器外,我们还可以加入自定义的类加载器,来实现特殊的加载方式。举例来说,我们可以对 class 文件进行加密,加载时再利用自定义的类加载器对其解密。
类的唯一性
除了加载功能之外,类加载器还提供了命名空间的作用。在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中,往往借助这一特性,来运行同一个类的不同版本。
链接
链接,是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。它分为验证、准备以及解析三个阶段。
-
验证阶段的目的,在于确保被加载类能够满足 Java 虚拟机的约束条件。
-
准备阶段
- 为被加载类的静态字段分配内存。
- 部分 Java 虚拟机还会在此阶段构造其他跟类层次相关的数据结构,比如说用来实现虚方法的动态绑定的方法表。
- 生成符号引用,这可以使被加载类找到诸如方法、字段这些具体目标的地址。
-
解析阶段
- 正是将这些符号引用解析成为实际引用。
- 如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化)
初始化
-
初始化,便是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。Java 虚拟机会通过加锁来确保类的 < clinit > 方法仅被执行一次。
-
类的初始化触发时机:
- 当虚拟机启动时,初始化用户指定的主类;
- 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
- 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
- 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
- 子类的初始化会触发父类的初始化;
- 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
- 使用反射 API 对某个类进行反射调用时,初始化这个类;
- 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。
-
举例:单例延迟初始化的例子(重要)(学完多线程后回来看,用语言描述它)
123456789 publicclassSingleton {`` ``privateSingleton() {}`` ``privatestaticclassLazyHolder {`` ``staticfinalSingleton INSTANCE = ``newSingleton();`` ``}`` ``publicstaticSingleton getInstance() {`` ``returnLazyHolder.INSTANCE;`` ``}``}
双亲委派模型★☆☆☆☆
就是每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。使用委派模型的目的是避免重复加载 Java 类型。
Java的方法调用
方法重载与重写
- 重载:想要在同一个类中定义名字相同的方法,那么它们的参数类型必须不同。又或者在子类中定义了与父类非私有方法同名但参数不同的方法。这些方法之间的关系,称之为重载。(重载的方法在编译过程中即可完成识别)
- 重写:子类定义了与父类中非私有非静态方法同名且参数类型相同的方法。
JVM 的静态绑定和动态绑定
静态绑定是解析时能够直接识别目标方法,动态绑定是在运行时根据调用者的动态类型识别目标方法
-
调用指令的符号引用
在 class 文件中,Java 编译器会用符号引用指代目标方法。在执行调用指令前,调用指令所附带的符号引用需要被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用为目标方法的指针。对于需要动态绑定的方法调用而言,实际引用为辅助动态绑定的信息。
JVM定位实例执行的方法
虚方法调用
- 虚方法调用包括 invokevirtual 指令和 invokeinterface 指令。如果这两种指令所声明的目标方法被标记为 final,那么 Java 虚拟机会采用静态绑定。否则,Java 虚拟机将采用动态绑定,在运行过程中根据调用者的动态类型,来决定具体的目标方法。
方法表
-
类加载机制的链接部分中,准备阶段除了会为静态字段分配内存之外,还会构造与该类相关联的方法表。
-
Java 虚拟机的动态绑定是通过方法表这一数据结构来实现的。方法表中每一个重写方法的索引值,与父类方法表中被重写的方法的索引值一致。
在解析虚方法调用时,Java 虚拟机会纪录下所声明的目标方法的索引值,并且在运行过程中根据这个索引值查找具体的目标方法。
内联缓存
- Java 虚拟机中的即时编译器会使用内联缓存来加速动态绑定。Java 虚拟机所采用的单态内联缓存将纪录调用者的动态类型,以及它所对应的目标方法。当碰到新的调用者时,如果其动态类型与缓存中的类型匹配,则直接调用缓存的目标方法。否则,Java 虚拟机将该内联缓存劣化为超多态内联缓存,在今后的执行过程中直接使用方法表进行动态绑定。
JVM的异常处理
异常的概念
- Java 的异常分为 Exception 和 Error 两种,而 Exception 又分为 RuntimeException 和其他类型。RuntimeException 和 Error 属于非检查异常。其他的 Exception 皆属于检查异常,在触发时需要显式捕获,或者在方法头用 throws 关键字声明。
Java虚拟机是如何捕获异常的
- 每个方法都附带一个异常表。异常表中的每一个条目代表一个异常处理器,并且由from指针、to指针、target指针以及所捕获的异常类型构成。这些指针的值是字节码索引,用以定位字节码
JVM的反射实现原理
反射调用的实现
-
Method.invoke实际上委派给 MethodAccessor 来处理。MethodAccessor 是一个接口,它有两个已有的具体实现:本地实现和委派实现。
-
本地实现:当进入了Java虚拟机内部之后,我们便拥有了Method实例所指向方法的具***置。这时候,反射调用无非就是将传入的参数准备好,然后调用进入目标方法。
-
委派实现:委派实现是Method实例和方法实现的中间层。每个Method实例的第一次反射调用都会生成一个委派实现,他所委派的具体实现便是一个本地实现(委派实现作为中间层)。本地实现非常容易理解。
-
为什么要用委派实现作为中间层而不是直接交给本地实现?
- 因为Java的反射调用机制还有一种动态生成字节码的实现,采用委派实现就是为了能够在本地实现以及动态实现中切换。动态实现犹豫无需JavaC++Java的切换,所以运行效率比本地实现快,但由于动态实现需要生成字节码十分耗时所以我们需要根据反射调用次数是否达到15调整委派实现的委派对象究竟为本地实现还是动态实现。
反射调用的开销
*方法的反射调用会带来不少性能开销,原因主要有三个:变长参数方法导致的 Object 数组,基本类型的自动装箱、拆箱,还有最重要的方法内联。
垃圾回收(重要,全部都要学会)
什么是垃圾回收
将已经分配出去的,但却不再使用的内存回收回来,以便能够再次分配,垃圾指的是死亡的对象所占据的堆空间。
判断对象是否能够回收的算法,能说出优缺点
引用计数法
- 定义:它的做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为 0,则说明该对象已经死亡,便可以被回收了。
- 具体实现:如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器 +1。如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器 -1。也就是说,我们需要截获所有的引用更新操作,并且相应地增减目标对象的引用计数器。
- 缺点:除了需要额外的空间来存储计数器,以及繁琐的更新操作,引用计数法还有一个重大的漏洞,那便是无法处理循环引用对象。
- 举例:假设对象 a 与 b 相互引用,除此之外没有其他引用指向 a 或者 b。在这种情况下,a 和 b 实际上已经死了,但由于它们的引用计数器皆不为 0,在引用计数法的心中,这两个对象还活着。因此,这些循环引用对象所占据的空间将不可回收,从而造成了内存泄露。
可达性分析
-
定义:通过一系列的“GC Roots”对象作为起点进行搜索,如果在“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的,不过要注意的是被判定为不可达的对象不一定就会成为可回收对象。被判定为不可达的对象要成为可回收对象必须至少经历两次标记过程,如果在这两次标记过程中仍然没有逃脱成为可回收对象的可能性,则基本上就真的成为可回收对象了。
GC Roots 包括(但不限于)如下几种:
- Java 方法栈桢中的局部变量;
- 已加载类的静态变量;
- JNI handles;
- 已启动且未停止的 Java 线程。
-
优点:可达性分析可以解决引用计数法所不能解决的循环引用问题。
-
缺点:在多线程环境下,其他线程可能会更新堆栈的状态,从而造成gc可能会收事实上仍被引用的对象内存。一旦从被原引用访问已经被回收了的对象则可能导致Java虚拟机崩溃。
安全点?Stop-the-world?
- stw存在的意义:为了解决gc错误回收仍被引用的对象内存的线程安全问题,我们需要用到stop-the-world机制,停止gc以外的线程的工作,知道完成gc。
- stw实现方式:JVM中的Stop-the-world是通过安全点机制来实现的。当JVM收到stop-the-world请求,它便会等待所有线程都到达安全点,才允许请求stop-the-world的gc线程进行gc工作。
- 安全点:安全点:就是说,当线程到达安全点的时候,在这个位置保存了线程上下文中的任何东西(包括对象,指向对象或非对象的内部指针),此时线程的状态信息都是确定的,只有这个时候,才知道这个线程用了哪些内存,没有用哪些;这个时候就可以stoptheworld让gc处理内存了,同时gc对内存的修改,所有处在安全点的线程也都是可以感知到的,并且这些对堆栈信息的修改一定是对线程的继续运行没有影响的,这就保证了线程安全。
垃圾回收算法
清除算法(sweep)
- 原理:即把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。
- 缺点:一是会造成内存碎片。由于 Java 虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。二是分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointer bumping)来做分配。而对于空闲列表,Java 虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。
压缩算法(compact)
- 原理:把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销很大。
复制算法(copy)
- 原理:把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。复制这种回收方式同样能够解决内存碎片化的问题。
- 缺点:它的缺点极其明显,即堆空间的使用效率极其低下。
常见的垃圾回收器
-
针对新生代的垃圾回收器:Serial New,Parallel Scavenge 和 Parallel New
-
针对老年代的垃圾回收器:Serial Old 和 Parallel Old,以及CMS
-
横跨新生代和老年代的垃圾回收器:G1(Garbage First)
JVM的分代回收思想
-
Java虚拟机的堆划分
-
Java虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为Eden区,以及两个大小相同的Survivor区(from区和to区)
-
Java虚拟机采取的是一种动态分配的策略(对应Java虚拟机参数 -XX:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及Sruvivor区的使用情况动态调整Eden区和Survivor区的比例。
-
TLAB技术:
- 目的:防止两个对象共用一段内存的事故。
- 实现:具体来说每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB。这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。接下来的 new 指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB 已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的 TLAB。当Eden区空间耗尽JVM会触发一次Minor GC。
-
-
GC(Gabage Collection)的过程
-
新生代GC(Minor GC) :
- 实现:新生代GC应用的GC算法是标记 - 复制算法,JVM收集新生代的垃圾,存活下来的对象将会被送到Survivor区。新生代共有两个Survivor区,我们分别用from和to来指代。其中to指向的Survivor区是空的。当发生新生代GC时,Eden区和from区种的存活对象会被复制到to区中,然后交换from和to指针,以保证下一次新生代GC时,to指向的Survivor区仍是空的。
- 新生代如何晋升至老年代? JVM会记录Survivor区中的对象一共被来回复制了几次,也就是经历了多少次新生代GC,当达到15次时,该对象将被晋升至老年代。另外,如果单个Survivor区已经被占用了50%,那么较高复制次数的对象也会被晋升至老年代。
-
老年代GC(Major GC) :
- 实现:具体取决于选择的 GC 选项,比比如标记 - 压缩算法——老年代中的无用对象被清除后,GC会将对象进行整理,以防止内存碎片化。
-
全堆GC(Full GC) :
- 非常耗时,对整个针对整个新生代、老生代、元空间的全局范围的GC。
-
-
卡表:解决新生代GC中老年代的对象可能引用新生代的对象发生的全堆扫描效率低的问题。
Java内存模型(JMM)
在介绍JMM之前先介绍硬件内存模型,JMM和硬件内存模型是相对应的
- CPU缓存:计算机执行程序时,是
C P U在执行每条指令,因为C P U要从内存读指令,又要根据指令指示去内存读写数据做运算,早期内存读写速度与C P U处理速度差距不大,所以没什么问题。随着C P U技术快速发展,C P U的速度越来越快,内存却没有太大的变化,导致内存的读写(IO)速度与C P U的处理速度差距越来越大,为了解决这个问题,引入了缓存(Cache)的设计,在C P U与内存之间加上缓存层(也就是寄存器与高速缓存L1L2L3)。 - CPU与内存的交互:
C P U运行时,会将指令与数据从主存复制到缓存层,后续的读写与运算都是基于缓存层的指令与数据,运算结束后,再将结果从缓存层写回主存。
Java内存模型,JMM都是建立在硬件内存模型基础上的抽象模型,并不是物理上的内存划分,简单说,为了使J V M在各平台下达到一致的内存交互效果,需要屏蔽下游不同硬件模型的交互差异,统一规范,为上游提供统一的使用接口。J M M就是这样一个保证J V M在各平台下对计算机内存的交互都能保证效果一致的机制及规范。**J M M**关于发布可JVM如何提供按需禁用缓存和编译优化的方法。包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则
- 程序顺序规则:一个线程中 【前面的操作】 happens-before于 该线程中 【后面的操作】
- 监视器锁规则:对锁的 【解锁】 ** happens-before于 后续对这个锁的【加锁】**
- volatile变量规则:对volatile变量的 【写】 ** happens-before于 后续对这个volatile变量的【读】**
- 线程 start() 规则:主线程 【启动子线程前的操作】 happens-before于 【子线程的操作】
- 线程 join() 规则: 【子线程的操作】 happens-before于【主线程中子线程的join()之后的操作】**
- 传递性:如果 【A happens-before B】且【B happens-before C】 ,那么 【A happens-before C】
J M M抽象结构
J M M抽象结构划分为线程本地缓存与主存,每个线程均有自己的本地缓存,本地缓存是线程私有的,主存则是计算机内存,它是共享的。
可以发现J M M与硬件内存模型差别不大,可以简单的把线程类比成Core核心,线程本地缓存类比成缓存层。
虽然内存交互规范好了,但是多线程场景必然存在线程安全问题(竞争共享资源),为了使多线程能正确的同步执行,就需要保证并发的三大特性可见性、原子性、有序性。
可见性
当一个线程修改了共享变量的值,其他线程能够立即得知这个修改,这就是可见性,如果无法保证,就会出现缓存一致性的问题。Java中提供了volatile修饰变量保证可见性,它的写操作,保证 happen-before 在随后对该变量的读取操作。
原子性
原子性是指一个或者多个操作在C P U执行的过程中不被中断的特性。在多线程场景仅保证可见性,没有保证原子性,同样会出现问题。Java中提供了synchronized和lock/unlock来保证结果的原子性。
有序性
程序运行时编译器为了优化性能,会对代码做重排,也就是指令重排序。编译器不会对存在数据依赖关系的操作做重排序以防改变执行结果,但指令重排只在单线程下保证执行结果不被改变,多线程场景没有这种保证。Java提供volatile修饰变量同时保证可见性、有序性,被volatile修饰的变量会加上内存屏障禁止排序。
三大特性的保证
| 特性 | volatile | synchronized | Lock | Atomic |
|---|---|---|---|---|
| 可见性 | 可以保证 | 可以保证 | 可以保证 | 可以保证 |
| 原子性 | 无法保证 | 可以保证 | 可以保证 | 可以保证 |
| 有序性 | 一定程度保证 | 可以保证 | 可以保证 | 无法保证 |