JVM(Java Virtual Machine)就是java虚拟机,于我而言,入行至今都没有对它有个比较清晰的认识,别人一说起来就是堆啊,栈啊,或者垃圾回收算法什么,讨论的一片火热,自己却只能在一旁瑟瑟发抖,别人看过来也只能露出一副尴尬而不失礼貌的微笑。不论是为了前途(钱途),还是为了其它原因,都有必要把它搞搞清楚。今天我们先对JVM的区域划分,关于垃圾回收的一些知识点重点学习一下。
JVM运行时数据区域划分
我们常提到的堆和栈属于JVM内存中的2个区域,我们只知道这俩,却不知道其它的区域划分(对他们的功能也什么模糊),现在就来看看。java8之后的方法区就不在JVM内存中了,转移到了本地内存。
JVM运行时数据区域划分我们可以看到,JVM主要被划分成为了5个区域,蓝色部分代表的就是线程私有的区域,就是每一个线程都有属于自己的虚拟机栈、本地方法栈、程序计数器。绿色部分代表的是所有线程共享的区域。
程序计数器:这一区域所占空间较小,是线程私有的,它的生命周期也是随着线程的启动而产生,线程的结束而死亡。在虚拟机中,唯一一个没有规定任何 OutOfMemoryError 的区域。那它到底是做什么的呢?和它的名字一样,就是用来计数的(只记录执行字节码时的行号),在执行字节码的时候,记录的是字节码的行号(代码执行到哪了);在执行Native 方法(本地方法,就是虚拟机中的方法)的时候,程序计数器并不会进行记录。可能现在说起来还是比较模糊的,我们举个例子,大家就明白了。我们知道Java是支持多线程的,在多线程并发的情况下,多个线程去争夺CPU的时间片来决定执行哪个线程,此时A线程抢到了时间片,执行了一部分(并未执行结束),又被B线程抢走了,抢来抢去,总会再次回到A线程手中,当A线程再次抢到时间片的时候,我难道还得重头再来?答案肯定是不是的,这个时候,程序计数器就体现了它的价值,直接从上次执行结束的位置开始执行。也间接的体现了程序计数器是线程私有的这一说法,不然这么多线程岂不是乱了套。
虚拟机栈:虚拟机栈就是们日常说的那个“栈”,描述的是java方法执行的内存模型,也是线程私有的,生命周期与线程一致。字节码中的每个方法在执行的时候都会生成一个栈帧(每个方法对应一个栈帧),用来存储这个方法的局部变量表、操作数栈、动态链接、方法出口。调用方法的时后创建栈帧,并压入虚拟机栈;方法执行结束,栈帧出栈并被销毁。在运行过程中,可抛出 StackOverflowError 和 OutOfMemoryError 异常。
1、局部变量表:指的是用来存储变量的空间。这里的变量指的是方法的参数及局部变量。
2、操作数栈:它里面主要存放的是一些算数运算用到的参数,也可能是中间结果,也可能是在调用其他方法时需要用到的参数。
3、动态链接:在运行过程中,符号引用解析为直接引用的过程就叫做是动态链接。生词太多了,我第一次看的时候也有点晕,想要弄懂这个,还得明白方法的调用过程,我们来分析一下。JVM在编译过程中,并不知道目标方法的具体内存地址。因此,会暂时用符号引用来表示该目标方法并存储在运行时常量池中。而我们在执行方法A的时候生成的栈帧会有一个引用,这个引用是用来去常量池中找A方法所对应的符号引用,之后将符号引用解析为直接引用,来实现方法的调用。
4、方法出口:就是方法执行结束,栈帧出栈。这里会有俩种情况,正常执行结束和异常执行结束。在方法退出之后,需要返回到方法被调用的位置,程序才能继续执行。
虚拟机栈本地方法栈:本地方法栈和虚拟机栈类似,同样是线程私有,运行时也会抛出StackOverflowError 和 OutOfMemoryError 异常。你可以理解为虚拟机栈执行的是字节码服务(java方法);而本地方法栈执行的是Native(JVM本地)方法服务。
方法区:方法区(Method Area)被所有线程共享,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
方法区是JVM的一项规范,而永久代和元空间是Hotspot 虚拟机对这个规范的俩种实现方式。在JAVA8之前, Hotspot 虚拟机中方法区的实现方式为永久代,在JAVA8之后就是元空间了。永久代和元空间它们的功能类似,最大的区别就是永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制。
堆:Java 虚拟机中内存最大的一块。Java 堆在虚拟机启动时创建,被所有线程共享。俗话说的好,new出来的都在堆里边。专业点的话就是用来存放对象的实例。GC(GarbageCollection)针对的主要就是它。
怎么判断实例是否失效(死亡)?
垃圾回收要明确回收的目标(死亡的实例),jvm提供了俩种方法引用计数法和可达性分析法。
引用计数法:给对象添加一个引用计数器,每当有一个地方引用它,计数器就+1,;当引用失效时,计数器就-1;当计数器为0时,表明该对象已经被废弃,不处于存活状态。但是很难解决对象之间的循环引用问题。例如A引用B,B引用A ,由于A、B彼此引用对方,导致引用计数都不为0,所以GC无法回收它们。
可达性分析法其实并不能说是解决了循环引用的问题,而是另辟蹊径,换了一种思路,避免了这种问题,以GC Roots这些暂时不会被回收的对象为起点,反向搜索判断是否被GC Roots对象引用,没有被引用就会被视为死亡。
可达性分析法:通过一系列为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明该对象是不可用的。如果对象在进行可行性分析后发现没有与GC Roots相连的引用链,也不会理解死亡。它会暂时被标记上并且进行一次筛选,筛选的条件是是否与必要执行finalize()方法。如果被判定有必要执行finaliza()方法,就会进入F-Queue队列中,并有一个虚拟机自动建立的、低优先级的线程去执行它。稍后GC将对F-Queue中的对象进行第二次小规模标记。如果这时还是没有新的关联出现,那基本上就真的被回收了。
可作为Root的对象:
1、虚拟机栈(栈帧中本地变量表)中引用的对象。
2、方法区中静态属性引用的对象。
3、方法区中常量引用的对象。
4、本地方法栈中Native方法引用的对象。
常见的垃圾回收算法
常见的垃圾回收算法主要有四种,包括了标记清除算法、复制清除算法、标记整理算法和分代收集算法。下边对这些算法进行详细介绍。
标记清除算法:标记清除算法分为标记和清除俩个阶段。标记阶段任务是标记出所有需要回收的对象,清除阶段就是清除被标记对象的空间。这种算法实现简单,但是效率不高,而且会产生不连续的内存碎片。
标记清除算法复制清除算法:将可用内存划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,把其中存活对象全部复制到另外一块中,然后再把已经使用过的内存空间一次清理掉。这种算法不会产生内存碎片,但是应为内存一分为二,导致可用内存空间少,在对象存活率较高的时候需要执行多次的复制操作,导致效率低下。
复制清除算法标记整理算法:标记整理算法和标记清除算法类似,在标记清除之中加了一个步骤,可以理解为标记、整理和清除三个阶段,标记和清除都好理解,整理是怎么回事呢?先标记存活对象,然后把存活对象向一边移动,然后清理掉边界以外的内存。这种算法不容易产生内存碎片,内存利用率高。但是在存活对象多并且分散的时候,移动次数多,效率低下。
标记整理算法分代收集算法:根据对象的存活周期,将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点,采用最适当的收集算法。新生代每次垃圾回收时会有大批对象死去,只有少量存活,所以选择复制清除算法。老年代中对象存活率高,所以使用“标记-整理”算法进行回收。
堆内存的回收机制
上边在分代收集算法中提到了将堆内存分为了新生代和老年代,通过它们各自的特点,选择适合自己的回收算法以提高回收效率,那么它们具体又是怎么实现的呢?在实现之前,我们必须先得了解内存分配规则:
1、对象优先分配在 Eden(Eden是什么?下边会介绍)。
2、大对象直接进入老年代。
3、长期存活的对象将进入老年代。设置-XX:MaxTenuringThreshold参数设置对象进入老年代的年龄阈值,当经历GC的次数满足MaxTenuringThreshold的时候,即可进入老年代。
4、动态对象年龄判定(年龄为经历GC的次数)。如果Survivor空间中相同年龄所有对象所占内存的大小大于Survivor空间的一半时,年龄大于或等于该年龄的对象将进入老年代。
5、空间分配担保。jdk6之后的版本分配担保规则为只要老年代的连续空间大于新生代对象总大小或历次晋升老年代对象的平均大小就进行Minor GC,否则进行Full GC。
Minor GC:新生代 GC,指发生在新生代的垃圾收集动作,因为 Java 对象大多死亡频繁,所以 Minor GC 非常频繁,一般回收速度较快。
Full GC:老年代 GC,也叫 Major GC,速度一般比 Minor GC 慢 10 倍以上。
堆内存GC示意图绿色部分为新生代,蓝色部分是老年代。我们可以看到将新生代按8:1:1的比例分为3部分,Eden,S1(Suvivor From)和S2(Suvivor To)。
先说一下新生代的回收机制,新生代采用了复制回收算法。年轻代主要用来存储新创建的对象,内存较小,垃圾回收频繁。那么为什么要把新生代分成三块内存呢,俩块可以吗?一块呢?答案都是可行的,只不过GC效率体现的有差异而已。
1、如果新生代只有Eden,刚刚新建的对象在Eden中,当Eden满了的时候会进行Minor GC,存活的对象会复制到老年代,这种情况下老年代很快就会被填满,导致触发Full GC,由于老年的内存远大于新生代,所以执行一次Full GC耗时很长。从前边可以看出增加Suvivor 区主要是为了多次筛选存活对象,减少老年的GC频率。
2、那么分为Eden和Suvivor呢。我们再来模拟一下,刚刚新建的对象在Eden中,等到Eden满了会触发GC,Eden中的存活对象就会被复制到Survivor区。等到下一次Eden满了的时候,再进行GC,此时Eden和Survivor都会有存活对象,如果此时把Eden区的存活对象再复制到Survivor区,会导致这俩部分对象所占有的内存不连续,产生内存碎片。内存碎片太多,没有足够大的连续内存空间,如果马上有一个“”“庞大”的对象需要你去分配内存,这可咋整?
3、目前最完美的解决方案,将新生代分为Eden、Suvivor From和Suvivor To。刚刚新建的对象在Eden中,等到Eden满了会触发GC,Eden中的存活对象就会被复制到Suvivor From,然后清空Eden;等Eden区再满了,就再一次触发 GC,Eden和Suvivor From中的存活对象又会被复制到Suvivor To中,由于Eden和Suvivor From这俩块内存中的存活对象都被转移到了一块空内存,保证了内存的连续性,解决了会产生内存碎片的问题。Suvivor From和Eden被清空,然后下一轮Suvivor From与Suvivor To交换角色。如果存活对象的复制次数达到16次,对象就会被送到老年代中。这里有人又要问了,那为什么要使Suvivor From与Suvivor To交换角色?为了保证俩块Suvivor中永远有一块是空的,这样就会始终保证内存的连续性,不会产生内存碎片。
最后说一下老年代的回收机制,老年代采用的是标记整理算法。通过新生代我们可以看出来,能复制到老年代的,基本上都是存活时间较长的对象,对象死亡率较低,所以采用标记整理算法,既保证了空间的连续性,也保证了GC的效率。
没有了~
网友评论