聊聊JVM吧

我们为什么要学习JVM呢,首先,面试会问!!!其次,当程序触发内存溢出等异常的时候,我们通过异常来判断异常产生原因,然后就是我们可以来优化我们的性能,避免垃圾代码的产生。

JVM是干什么的

众所周知,java是跨平台的语言,主打的就是一次编译,到处运行。而之所以能实现这个功能,就是因为JVM,那么JVM干什么了呢?

JVM说白了就是从软件层面屏蔽了底层硬件,指令层面的细节。它将字节码文件解释成为特定的机器码进行运行,使之实现上面说的跨平台效果。

JVM里面有什么

JVM由三个主要的子系统构成

1、类加载子系统

2、运行时数据区(内存结构)

3、执行引擎

类装载器

    每一个Java虚拟机都由一个类加载器子系统(class loader subsystem),负责加载程序中的类型(类和接口),并赋予唯一的名字。

执行引擎

它或者在执行字节码,或者执行本地方法

运行时数据区(重要)

操作系统说白了就是对数据和指令的操作,所以运行时数据区我们也可以分为数据和指令两大类,同时GC也发生在方法区和堆里面。

程序计数器

记录了当前线程执行的位置。说白了就是记住我执行到那步了,我们都知道现在的系统都是并发的,所以,我们才需要知道我们执行到那步了,等到线程再次执行才能无缝接轨。

虚拟机栈

Java线程执行方法的内存模型,一个线程对应一个栈,所以说,他是线程私有的。每个方法在执行的同时都会创建一个栈帧(用于存储局部变量表,操作数栈,动态链接,方法出口等信息)不存在垃圾回收问题,只要线程一结束该栈就释放,生命周期和线程一致

栈中的元素用于支持虚拟机进行方法调用,每个方法从开始调用到执行完成的过程,就是栈帧从入栈到出栈的过程在活动线程中,只有位于栈顶的帧才是有效的,称为当前栈帧,正在执行的方法称为当前方法,栈帧是方法运行的基本结构

在执行引擎运行时,所有指令都只能针对当前栈帧进行操作

在 Java 虚拟机规范中,对这个区域规定了两种异常情况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常。(单线程独有)
  • 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常。(多线程会发生)

如果你想看具体的方法是怎么执行的  我们可以查看java的字节码文件反汇编出来的指令文件

局部变量:

我们的局部变量里面保存的是方法参数以及局部变量。

局部变量以一个字长为单位(32位长度),所以声明double类型会占用两个单位。

局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。(因为我们java是强类型语言,每个变量已经确定好了类型)

操作数栈:

操作栈是一个初始状态为空的桶式结构栈

在方法执行过程中,会有各种指令往栈中写入和提取信息

JVM的执行引擎是基于栈的执行引擎,其中的栈指的就是操作数栈

动态链接:

每个栈帧都包含一个指向运行时常量池(在方法区中,后面介绍)中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。

虚拟机运行的时候,运行时常量池会保存大量的符号引用,这些符号引用可以看成是每个方法的间接引用

如果代表栈帧A的方法想调用代表栈帧B的方法,那么这个虚拟机的方法调用指令就会以B方法的符号引用作为参数,但是因为符号引用并不是直接指向代表B方法的内存位置,所以在调用之前还必须要将符号引用转换为直接引用,然后通过直接引用才可以访问到真正的方法。(说白了就是嵌套调用其他方法用的)

出口

没啥说的,就是方法或者顺利或者不顺利(异常)的执行结束。

本地方法栈

和栈作用很相似,区别不过是Java栈为JVM执行Java方法服务,而本地方法栈为JVM执行操作系统native方法服务。(没啥用吧)


重要的来了

方法区

方法区,又被成为non-heap,就是为了和堆区分开,方法区也是各个线程共享的内存区域,其中保存了类信息(class信息),常量,还有静态常量,运行时常量池

方法区又被称为“永久代”(<JDK1.8),另外,虚拟机规范允许该区域可以选择不实现垃圾回收。相对而言,垃圾收集行为在这个区域比较少出现。该区域的内存回收目标主要针是对废弃常量的和无用类的回收。

在JDK1.8之后,元空间替代了永久代,它是方法区的实现,区别在于元数据区不在虚拟机当中,而是用的本地内存,永久代在虚拟机当中,永久代逻辑结构上也属于堆,但是物理上不属于。

根据 Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常(很少出现)。

堆(heap)

虚拟机启动时自动分配创建,用于存放对象的实例,几乎所有对象(包括常量池)都在堆上分配内存,当对象无法在
该空间申请到内存是将抛出OutOfMemoryError异常。同时也是垃圾收集器管理的主要区域。

新生代

新生代分为三个区域,一个Eden区和两个Survivor区,它们之间的比例为(8:1:1),这个比例也是可以修改的。

通常情况下,对象主要分配在新生代的Eden区上,少数情况下也可能会直接分配在老年代中(分配担保机制,下面会说)。

Java虚拟机每次使用新生代中的Eden和其中一块Survivor(From),在经过一次Minor GC(eden区满了)后,将Eden和Survivor中还存活的对象一次性地复制到另一块Survivor空间上(这里使用的复制回收算法进行GC),最后清理掉Eden和刚才用过的Survivor(From)空间,此时from和to会互换身份。

将此时在Survivor空间存活下来的对象的年龄设置为1,以后这些对象每在Survivor区熬过一次GC,它们的年龄就加1,当对象年龄达到某个年龄(默认值为15)时,就会把它们移到老年代中。

在新生代中进行GC时,有可能遇到另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代;

注:堆=新生代+老年代

内存泄漏

内存泄漏一般可以理解为系统资源(各方面的资源,堆、栈、线程等)在错误使用的情况下,导致使用完毕的资源无法回收(或没有回收),从而导致新的资源分配请求无法完成,引起系统错误。

整个JVM内存大小=年轻代大小 + 年老代大小 + 持久代大小。

目前来说,常遇到的泄漏问题如下:

1、老年代堆空间被占满

 异常: java.lang.OutOfMemoryError: Java heap space

这是最典型的内存泄漏方式,简单说就是所有堆空间都被无法回收的垃圾对象占满,虚拟机无法再在分配新空间。

这种情况一般来说是因为内存泄漏或者内存不足造成的。

某些情况因为长期的无法释放对象,运行时间长了以后导致对象数量增多,从而导致的内存泄漏。

另外一种就是因为系统的原因,大并发加上大对象,Survivor Space区域内存不够,大量的对象进入到了老年代,然而老年代的内存也不足时,从而产生了Full GC,但是这个时候Full GC也无发回收。这个时候就会产生

java.lang.OutOfMemoryError: Java heap space

2、方法区被占满

异常:java.lang.OutOfMemoryError: PermGen space

Perm空间被占满。无法为新的class分配存储空间而引发的异常。

这个异常以前是没有的,但是在Java反射大量使用的今天这个异常比较常见了。主要原因就是大量动态反射生成的类不断被加载,最终导致Perm区被占满。

3、堆栈溢出

异常:java.lang.StackOverflowError

一般就是递归,或者循环调用造成

4、线程堆栈满

异常:Fatal: Stack size too small

java中一个线程的空间大小是有限制的。JDK5.0以后这个值是1M。与这个线程相关的数据将会保存在其中。但是当线程空间满了以后,将会出现上面异常。

GC

JVM分别对新生代和老年代采用不同的垃圾回收机制。

GC触发条件:

Eden区满了触发Minor GC,这时会把Eden区存活的对象复制到S区,当对象在Survivor区熬过一定次数的Minor GC之后,就会晋升到老年代,当老年代满了,就会报OutofMemory异常。

新生代的GC(Minor GC):

新生代通常存活时间较短,基于复制算法进行回收,所谓复制算法就是扫描出存活的对象,并复制到一块新的完全未使用的空间中。

对应于新生代,就是在Eden和FromSpace或ToSpace之间copy。新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC。当连续分配对象时,对象会逐渐从Eden到Survivor,最后到老年代。

在执行机制上JVM提供了串行GC(SerialGC)、并行回收GC(ParallelScavenge)和并行GC(ParNew):

串行GC

在整个扫描和复制过程采用单线程的方式来进行,适用於单CPU、新生代空间较小及对暂停时间要求不是非常高的应用上,是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定。

并行回收GC

在整个扫描和复制过程采用多线程的方式来进行,适用于多CPU、对暂停时间要求较短的应用上,是server级别默认采用的GC方式,可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数。

并行GC

与老年代的并发GC配合使用。

老年代的GC(Major GC/Full GC):

Major GC 是清理老年代。 Full GC 是清理整个堆空间(包括新生代和永久代)。

老年代与新生代不同,老年代对象存活的时间比较长、比较稳定,因此采用标记(Mark)算法来进行回收,所谓标记就是扫描出存活的对象,然后再进行回收未被标记的对象,回收后对用空出的空间要么进行合并、要么标记出来便于下次进行分配,总之目的就是要减少内存碎片带来的效率损耗。

在执行机制上JVM提供了串行GC(Serial MSC)、并行GC(Parallel MSC)和并发GC(CMS)。

串行GC(Serial MSC)

client模式下的默认GC方式,可通过-XX:+UseSerialGC强制指定。每次进行全部回收,进行Compact,非常耗费时间。

并行GC(Parallel MSC)

吞吐量大,但是GC的时候响应很慢

server模式下的默认GC方式,也可用-XX:+UseParallelGC=强制指定。可以在选项后加等号来制定并行的线程数。

并发GC(CMS)

响应比并行gc快很多,但是牺牲了一定的吞吐量

垃圾回收算法

1.1 引用计数器算法

引用计数器算法是给每个对象设置一个计数器,当有地方引用这个对象的时候,计数器+1,当引用失效的时候,计数器-1,当计数器为0的时候,JVM就认为对象不再被使用,是“垃圾”了。

引用计数器实现简单,效率高;但是不能解决循环引用问问题(A对象引用B对象,B对象又引用A对象,但是A,B对象已不被任何其他对象引用),同时每次计数器的增加和减少都带来了很多额外的开销,所以在JDK1.1之后,这个算法已经不再使用了。

1.2 可达性分析算法

可达性分析算法是通过一些“GC Roots”对象作为起点,从这些节点开始往下搜索,搜索通过的路径成为引用链(Reference Chain),当一个对象没有被GC Roots的引用链连接的时候,说明这个对象是不可用的,如下图所示。

GC Roots对象包括:

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

  2. 方法区中的类静态属性引用的对象。

  3. 方法区中常量引用的对象。

  4. 本地方法栈中JNI(Native方法)的引用的对象。

上面只是标记了对象是否可以被回收,实际上在java中首先会标记下对象,会调用对象里面的protected void finalize()这个方法,这个时候对象还有救,只要在这个方法把该对象和引用链对接上,其实可以逃脱被回收

1.3 标记—清除算法

标记—清除算法包括两个阶段:“标记”和“清除”。在标记阶段,确定所有要回收的对象,并做标记。清除阶段紧随标记阶段,将标记阶段确定不可用的对象清除。

标记—清除算法是基础的收集算法,标记和清除阶段的效率不高,而且清除后回产生大量的不连续空间,这样当程序需要分配大内存对象时,可能无法找到足够的连续空间。如下图所示:

 

1.4 复制算法

复制算法是把内存分成大小相等的两块,每次使用其中一块,当垃圾回收的时候,把存活的对象复制到另一块上,然后把这块内存整个清理掉。这种方式听上去确实是非常不错的方案,但是总的来说对内存的消耗十分高。

复制算法实现简单,运行效率高,但是由于每次只能使用其中的一半,造成内存的利用率不高。现在的JVM用复制方法收集新生代,由于新生代中大部分对象(98%)都是朝生夕死的,所以两块内存的比例不是1:1(大概是8:1),也就是常提到的一块Eden(80%)和两块Survivor(20%)。当然也会存在10%不够用的情况,这个后面在进行梳理,会有一个补偿机制,也就是分配担保

1.5 标记—整理算法

复制收集算法会存在一种极端情况,就是对象都没死。这种情况会在老年代有机率的出现,所以根据老年代的特点提出了标记—整理算法。 标记—整理算法和标记—清除算法一样,但是标记—整理算法不是把存活对象复制到另一块内存,而是把存活对象往内存的一端移动,然后直接回收边界以外的内存,如下图所示:

 

1.6 分代收集

分代收集是根据对象的存活时间把内存分为新生代和老年代,根据个代对象的存活特点,每个代采用不同的垃圾回收算法。

新生代采用标记—复制算法,老年代采用标记—整理算法。

垃圾算法的实现涉及大量的程序细节,而且不同的虚拟机平台实现的方法也各不相同。上面介绍的只不过是基本思想。

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章