深入理解java虚拟机

作者: 战斗民族就是干

转载请注明出处:https://i.cnblogs.com/EditPosts.aspx?postid=5515245

 

一、运行时数据区域

     线程隔离:线程隔离的意思,就是给不同的线程多分配的资源用,以做到不争用

     线程共享:线程共享就是资源只有一个没有办法分配更多,只能共享

   Java虚拟机管理的内存包括几个运行时数据内存方法区、虚拟机栈、本地方法栈、堆、程序计数器,其中方法区和堆是由线程共享的数据区,其他几个是线程隔离的数据区。程序计数器,虚拟机栈,本地方法栈,随线程而生,线程亡而亡

1.1 程序计数器

  程序计数器是一块较小的内存,他可以看做是当前线程所执行的行号指示器。字节码解释器工作的时候就是通过改变这个计数器的值来选取下一条需要执行的字节码的指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器则为空。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemotyError情况的区域

线程私有的内存:

  由于java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,在任何一个确定的时间,一个处理器(对多核处理器来说是一个内核)只会执行一条线程中的指令。因此为了为了线程切换能够恢复到正确的执行位置上,每条线程都有一个独立的线程计数器,各条线程之间计数器互不影响,独立存储,我们叫这类内存区域线程私有的内存

 

1.2 Java虚拟机栈

  虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于储存局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

  栈内存就是虚拟机栈,或者说是虚拟机栈中局部变量表的部分

  局部变量表存放了编辑期可知的各种基本数据类型(booleanbytecharshortintfloatlongdouble)、对象引用(refrence)类型和returnAddress类型(指向了一条字节码指令的地址)

  其中64位长度的longdouble类型的数据会占用两个局部变量空间,其余的数据类型只占用1个。

  局部变量表所需的内存空间在编译器间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小

  Java虚拟机规范对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。如果虚拟机扩展时无法申请到足够的内存,就会跑出OutOfMemoryError异常

1.3 本地方法栈

  本地方法栈和虚拟机栈发挥的作用是非常类似的,他们的区别是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务

  本地方法栈区域也会抛出StackOverflowErrorOutOfMemoryErroy异常

1.4 Java

  堆是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动的时候创建,此内存区域的唯一目的是存放对象实例,几乎所有的对象实例都在这里分配内存。所有的对象实例和数组都在堆上分配

  Java堆是垃圾收集器管理的主要区域。Java堆细分为新生代和老年代。不管怎样,划分的目的都是为了更好的回收内存,或者更快地分配内存

  Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。如果在堆中没有完成实例分配,并且堆也无法在扩展时将会抛出OutOfMemoryError异常

1.5 方法区

  方法区它用于储存已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

  除了Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载

  当方法区无法满足内存分配需求时,将抛出OutOfMemoryErroy异常

1.6 运行时常量池

  它是方法区的一部分。Class文件中除了有关的版本、字段、方法、接口等描述信息外、还有一项信息是常量池,用于存放编辑期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放

  Java语言并不要求常量一定只有编辑期才能产生,也就是可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法

  当常量池无法再申请到内存时会抛出OutOfMemoryError异常

1.7 直接内存

  直接内存并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域。

  NIO类是一种基于通道和缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个储存在Java堆中的DirectByteBuffer对象作为这块直接内存的引用进行操作,这样避免了java堆和navie堆中来回复制数据

 

二、hotspot虚拟机对象

2.1 对象的创建

1.检查 

  虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程

2.分配内存 

  接下来将为新生对象分配内存,对象所需内存在类加载完毕之后就可以完全确定,为对象分配内存空间的任务等同于把一块确定的大小的内存从Java堆中划分出来

  假设Java堆中内存是绝对规整的,所有用过的内存放在一遍,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针指向空闲空间那边挪动一段与对象大小相等的距离,这个分配方式叫做“指针碰撞”

  如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式成为“空闲列表

  选择那种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

  在分配内存的时候会出现并发的问题,比如在给A对象分配内存的时候,指针还没有来得及修改,对象B又同时使用了原来的指针进行了内存的分片。

  有两个解决方案:

  1、对分配的内存进行同步处理:CAS配上失败重试的方式保证更新操作的原子性

  2、把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在java堆中分配一块小内存,称为本地缓冲区,那个线程需要分配内存,就需要在本地缓冲区上进行,只有当缓冲区用完并分配新的缓冲区的时候,才需要同步锁定,

3. Init

  执行new指令之后会接着执行Init方法,进行初始化,这样一个对象才算产生出来

2.2 对象的内存布局

  在HotSpot虚拟机中,对象在内存中储存的布局可以分为3块区域:对象头、实例数据和对齐填充

  对象头包括两部分

  a) 储存对象自身的运行时数据,如哈希码、GC分带年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳

  b) 另一部分是指类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例

  实例数据:

  是对象正常储存的有效信息,也是程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录下来。

  对齐填充:

  不是必然存在的,仅仅是起到占位符的作用。对象的大小必须是8字节的整数倍,而对象头刚好是8字节的整数倍(1倍或者2倍),当实例数据没有对齐的时候,就需要通过对齐填充来补全

2.3 对象的访问定位

  1、使用句柄访问

  Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址

  优势:reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改

  

  2、使用直接指针访问

  Java堆对象的布局就必须考虑如何访问类型数据的相关信息,refreence中存储的直接就是对象的地址

     优势:速度更快,节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本

  

 

三、OutOfMemoryError 异常(OOM)

3.1 Java堆溢出

  Java堆用于存储对象实例,只要不断的创建对象,并且保证GCRoots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在数量到达最大堆的容量限制后就会产生内存溢出异常

如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄露对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置

如果不存在泄露,换句话说,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗

3.2 虚拟机栈和本地方法栈溢出

  对于HotSpot来说,虽然-Xoss参数(设置本地方法栈大小)存在,但实际上是无效的,栈容量只由-Xss参数设定。关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:

  如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError

  如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常

  在单线程下,无论由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常

  如果是多线程导致的内存溢出,与栈空间是否足够大并不存在任何联系,这个时候每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。解决的时候是在不能减少线程数或更换64为的虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程

3.3 方法区和运行时常量池溢出

  String.intern()是一个Native方法,它的作用是:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用

  由于常量池分配在永久代中,可以通过-XX:PermSize-XX:MaxPermSize限制方法区大小,从而间接限制其中常量池的容量。

Intern():

  JDK1.6 intern方法会把首次遇到的字符串实例复制到永久代,返回的也是永久代中这个字符串实例的引用,而由StringBuilder创建的字符串实例在Java堆上,所以必然不是一个引用

  JDK1.7 intern()方法的实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此intern()返回的引用和由StringBuilder创建的那个字符串实例是同一个

 

四、垃圾收集

  程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了

  栈中的栈帧随着方法的进入和退出就有条不紊的执行者出栈和入栈的操作,每一个栈分配多少个内存基本都是在类结构确定下来的时候就已经确定了,这几个区域内存分配和回收都具有确定性

  而堆和方法区则不同,一个接口的实现是多种多样的,多个实现类需要的内存可能不一样,一个方法中多个分支需要的内存也不一样,我们只能在程序运行的期间知道需要创建那些对象,分配多少内存,这部分的内存分配和回收都是动态的

1.判断对象存活

4.1.1 引用计数器法

  给对象添加一个引用计数器,每当由一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的

4.1.2 可达性分析算法

  通过一系列的成为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC ROOTS没有任何引用链相连时,则证明此对象时不可用的

  Java语言中GC Roots的对象包括下面几种:

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

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

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

  4.本地方法栈JNINative方法)引用的对象

2.引用

  强引用就是在程序代码之中普遍存在的,类似Object obj = new Object() 这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象

  软引用用来描述一些还有用但并非必须的元素。对于它在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存才会抛出内存溢出异常

  弱引用用来描述非必须对象的,但是它的强度比软引用更弱一些,被引用关联的对象只能生存到下一次垃圾收集发生之前,当垃圾收集器工作时,无论当前内存是否足够都会回收掉只被弱引用关联的对象

  虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知

3.Finalize方法

  任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了

4.3.1 回收方法区

  永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类

  废弃常量:假如一个字符串abc已经进入了常量池中,如果当前系统没有任何一个String对象abc,也就是没有任何Stirng对象引用常量池的abc常量,也没有其他地方引用的这个字面量,这个时候发生内存回收这个常量就会被清理出常量池

  无用的类:

  1.该类所有的实例都已经被回收,就是Java堆中不存在该类的任何实例

  2.加载该类的ClassLoader已经被回收

  3.该类对用的java.lang.Class对象没有在任何地方被引用,无法再任何地方通过反射访问该类的方法

  4.垃圾收集算法

4.4.1 标记—清除算法(mark-sweep)

  算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象、

  不足:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清楚之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后再程序运行过程中需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

4.4.2 复制算法(copying)

  他将可用内存按照容量划分为大小相等的两块,每次只使用其中的一块。当这块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可

  不足:将内存缩小为了原来的一半

  实际中我们并不需要按照1:1比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor

  当另一个Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代

4.4.3 标记整理算法(mark-compact)

  让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

4.4.4 分代收集算法

  只是根据对象存活周期的不同将内存划分为几块。一般是把java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记清理或者标记整理算法来进行回收

5.垃圾收集器

a)Serial收集器:

  这个收集器是一个单线程的收集器,但它的单线程的意义不仅仅说明它会只使用一个COU或一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程,直到它手机结束

b)ParNew 收集器:

  Serial收集器的多线程版本,除了使用了多线程进行收集之外,其余行为和Serial收集器一样

  并行:指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态

  并发:指用户线程与垃圾收集线程同时执行(不一定是并行的,可能会交替执行),用户程序在继续执行,而垃圾收集程序运行于另一个CPU

c)Parallel Scavenge 

  是一个新生代收集器,它是使用复制算法的收集器,又是并行的多线程收集器。

  吞吐量:就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)

d)Serial Old 收集器:

  Serial收集器的老年代版本,是一个单线程收集器,使用标记整理算法

e)Parallel Old 收集器:

  Parallel OldParaller Seavenge收集器的老年代版本,使用多线程和标记整理算法

f)CMS收集器:

  CMS并非没有暂停,而是用两次短暂停来替代串行标记整理算法的长暂停,它的收集周期是这样:

  初始标记(CMS-initial-mark) -> 并发标记(CMS-concurrent-mark) -> 重新标记(CMS-remark) -> 并发清除(CMS-concurrent-sweep) ->并发重设状态等待下次CMS的触发(CMS-concurrent-reset)。

  其中的1,3两个步骤需要暂停所有的应用程序线程的。第一次暂停从root对象开始标记存活的对象,这个阶段称为初始标记;第二次暂停是在并发标记之后, 暂停所有应用程序线程,重新标记并发标记阶段遗漏的对象(在并发标记阶段结束后对象状态的更新导致)。第一次暂停会比较短,第二次暂停通常会比较长,并且 remark这个阶段可以并行标记。
  而并发标记、并发清除、并发重设阶段的所谓并发,是指一个或者多个垃圾回收线程和应用程序线程并发地运行,垃圾回收线程不会暂停应用程序的执行,如果你有多于一个处理器,那么并发收集线程将与应用线程在不同的处理器上运行,显然,这样的开销就是会降低应用的吞吐量。Remark阶段的并行,是指暂停了所有应用程序后,启动一定数目的垃圾回收进程进行并行标记,此时的应用线程是暂停的

  CMS收集器是基于标记清除算法实现的,整个过程分为4个步骤:

  1.初始标记2.并发标记3.重新标记4.并发清除

  优点:并发收集、低停顿

  缺点:

  1.CMS收集器对CPU资源非常敏感,CMS默认启动的回收线程数是(CPU数量+3/4

  2.CMS收集器无法处理浮动垃圾,可能出现Failure失败而导致一次Full G场地产生

  3.CMS是基于标记清除算法实现的

g)G1收集器:

  它是一款面向服务器应用的垃圾收集器

  1.并行与并发:利用多CPU缩短STOP-The-World停顿的时间

  2.分代收集

  3.空间整合:不会产生内存碎片

  4.可预测的停顿

  运作方式:初始标记,并发标记,最终标记,筛选回收

6.内存分配与回收策略

  MinorGC:清理新生代

  MajorGC:清理老年代

  FullGC:清理整个堆空间

4.6.1 对象优先在Eden分配:

  大多数情况对象在新生代Eden区分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC

4.6.2 大对象直接进入老年代:

  所谓大对象就是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。这样做的目的是避免Eden区及两个Servivor之间发生大量的内存复制

4.6.3长期存活的对象将进入老年代

  如果对象在Eden区出生并且经历过一次Minor GC后仍然存活,并且能够被Servivor容纳,将被移动到Servivor空间中,并且把对象年龄设置成为1.对象在Servivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁),就将会被晋级到老年代中

4.6.4动态对象年龄判定

  为了更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋级到老年代,如果在Servivor空间中相同年龄所有对象的大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入到老年代,无须登到MaxTenuringThreshold中要求的年龄

4.6.4 空间分配担保:

  在发生Minor GC 之前,虚拟机会检查老年代最大可 用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许那么会继续检查老年代最大可用的连续空间是否