从String,StringBuilder和StringBuffer的使用谈起JVM的内存区域与内存分配(二)

版权声明:本文为博主原创文章,转载请留下脚印,技术交流评论。 https://blog.csdn.net/ZLZ2017/article/details/84933385

1、概要

         上一节中谈了String,StringBuilder和StringBuffer的区别和使用,并简单说明了各变量的内存分配等。做过C++编程的同学总是自己管理内存,一不留神就会造成野指针或者内存泄漏。Java的自动内存管理机制,“不需要”自己管理内存,统一由JVM管理。这一节将简要介绍java的内存区域和内存分配(本篇为了准确,以《java虚拟机》简要总结)。

2、运行时数据区域

       大多数 JVM 将内存区域划分为 方法区(Method Area)、堆(Heap)、程序计数器(Program Counter Register)、虚拟机栈(VM Stack)、本地方法栈(Native Method Stack )。如上图,其中堆和方法区线程共享的,程序计数器、虚拟机栈和本地方法栈是非线程共享的。所谓线程共享,即各线程都可以使用这部分内存区域,其生命周期与程序的生命周期一致;非线程共享,是指每个线程拥有自己独立的虚拟栈、本地方法栈和程序计数器,其生命周期和拥有的线程的一致。

  1. 程序计数器(Program Counter Register)是一块较小的内存空间它可以看作是当前线程所执行的字节码的行号指示器。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域
  2. Java虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame[1])用于存储局部变量表、 操作数栈、 动态链接、 方法出口等信息。 每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。局部变量表所需的内存空间在编译期间完成分配,在方法运行期间不会改变局部变量表的大小。
  3. 本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间
    的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
  4. Java(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。 此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。 这一点在Java虚拟机规范中的描述是:几乎所有的对象实例以及数组都要在堆上分配。
  5. 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息(版本、字段、方法、接口等)、 常量、 静态变量、 即时编译器编译后的代码等数据。
  6. 直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。 这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

        一般来说对象在内存中存储的布局可以分为3块区域: 对象头( Header) 、实例数据( Instance Data) 和对齐填充( Padding)。对象头主要包括两部分信息, 一部分用于存储对象自身的运行时数据,如哈希码( HashCode) 、 GC分代年龄、 锁状态标志、 线程持有的锁、 偏向线程ID、 偏向时间戳等;另一部分是类型指针, 即对象指向它的类元数据的指针, 虚拟机通过这个指针来确定这个对象是哪个类的实例。 并不是所有的虚拟机实现都必须在对象数据上保留类型指针, 换句话说, 查找对象的元数据信息并不一定要经过对象本身;另外, 如果对象是一个Java数组, 那在对象头中还必须有一块用于记录数组长度的数据, 因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小, 但是从数组的元数据中却无法确定数组的大小。实例数据部分是对象真正存储的有效信息, 也是在程序代码中所定义的各种类型的字段内容。对齐填充并不是必然存在的, 也没有特别的含义, 它仅仅起着占位符的作用。

       Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由JIT编译器进行一些优化,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。

3、垃圾收集器和内存分配策略

3.1、对象存活判定算法

  1. 引用计数算法:给对象中添加一个引用计数器, 每当有一个地方引用它时, 计数器值就加1; 当引用失效时, 计数器值就减1; 任何时刻计数器为0的对象就是不可能再被使用的。引用计数算法很难解决对象之间相互循环引用的问题。
  2. 可达性分析法(ReachabilityAnalysis):通过一系列的称为“GCRoots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(ReferenceChain),当一个对象到GCRoots没有任何引用链相连,(用图论的话来说,就是从GCRoots到这个对象不可达)时,则证明此对象是不可用的。在主流的商用程序语言(Java、C#,甚至包括前面提到的古老的Lisp)的主流实现中,都使用该算法。

3.2、垃圾收集算法

3.2.1、标记-清除算法

       “标记-清除”(Mark-Sweep)算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它的主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。后续的收集算法都是基于这种思路并对其不足进行改进而得到的。

3.2.2、复制算法

       为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。现在的商业虚拟机都采用这种收集算法来回收新生代,新生代将内存分为一块较大的Eden空间和两块较小的Survivor空间, 每次使用Eden和其中一块Survivor。当回收时, 将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上, 最后清理掉Eden和刚才用过的Survivor空间。当Survivor空间不够用时, 需要老年代进行分配担保( Handle Promotion)。

3.2.3、标记-整理算法

       标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,一般用于老年代的垃圾回收。

3.2.4、分代收集算法

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

3.3、垃圾收集器

垃圾收集器就是内存回收的具体实现,下图是《Java虚拟机》中的图。

      上图展示了7种作用于不同分代的收集器, 如果两个收集器之间存在连线, 就说明它们可以搭配使用。 虚拟机所处的区域, 则表示它是属于新生代收集器还是老年代收集器。

3.3.1、Serial收集器

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

3.3.2、ParNew收集器

       ParNew收集器其实就是Serial收集器的多线程版本,与Serial收集器几乎完全一样, 在实现上, 这两种收集器也共用了相当多的代码。 ParNew收集器的工作过程如图所示。

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

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

3.3.3、Parallel Scavenge收集器

        Parallel Scavenge收集器是一个新生代收集器, 它也是使用复制算法的收集器, 又是并行的多线程收集器。Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量( Throughput) 。 所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值, 即吞吐量=运行用户代码时间/( 运行用户代码时间+垃圾收集时间) , 虚拟机总共运行了100分钟, 其中垃圾收集花掉1分钟, 那吞吐量就是99%。

        停顿时间越短就越适合需要与用户交互的程序, 良好的响应速度能提升用户体验, 而高吞吐量则可以高效率地利用CPU时间, 尽快完成程序的运算任务, 主要适合在后台运算而不需要太多交互的任务。

        GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的: 系统把新生代调小一些, 收集300MB新生代肯定比收集500MB快吧, 这也直接导致垃圾收集发生得更频繁一些, 原来10秒收集一次、 每次停顿100毫秒, 现在变成5秒收集一次、 每次停顿70毫秒。 停顿时间的确在下降, 但吞吐量也降下来了

3.3.4、Serial Old收集器

       Serial Old是Serial收集器的老年代版本, 它同样是一个单线程收集器, 使用“标记-整理”算法。 这个收集器的主要意义也是在于给Client模式下的虚拟机使用。

3.3.5、Parallel Old收集器

       Parallel Old是Parallel Scavenge收集器的老年代版本, 使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供的, 在此之前, 新生代的Parallel Scavenge收集器一直处于比较尴尬的状态。 原因是, 如果新生代选择了Parallel Scavenge收集器, 老年代除了Serial Old( PS MarkSweep) 收集器外别无选择( 还记得上面说过Parallel Scavenge收集器无法与CMS收集器配合工作吗? ) 。 由于老年代Serial Old收集器在服务端应用性能上的“拖累”, 使用了Parallel Scavenge收集器也未必能在整体应用上获得吞吐量最大化的效果, 由于单线程的老年代收集中无法充分利用服务器多CPU的处理能力, 在老年代很大而且硬件比较高级的环境中, 这种组合的吞吐量甚至还不一定有ParNew加CMS的组合“给力”。

       在注重吞吐量以及CPU资源敏感的场合, 都可以优先考虑Parallel Scavenge加Parallel Old收集器。 Parallel Old收集器的工作过程如图:

 

3.3.6、CMS收集器

       CMS( Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。 目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上, 这类应用尤其重视服务的响应速度, 希望系统停顿时间最短, 以给用户带来较好的体验。 CMS收集器就非常符合这类应用的需求。

       CMS收集器是基于“标记—清除”算法实现的, 它的运作过程相对于前面几种收集器来说更复杂一些, 整个过程分为4个步骤, 包括:初始标记( CMS initial mark)、并发标记( CMS concurrent mark)、重新标记( CMS remark)、并发清除( CMS concurrent sweep。其中, 初始标记、 重新标记这两个步骤仍然需要“Stop The World”。 初始标记仅仅只是标记一下GC Roots能直接关联到的对象, 速度很快, 并发标记阶段就是进行GC RootsTracing
的过程, 而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录, 这个阶段的停顿时间一般会比初始标记阶段稍长一些, 但远比并发标记的时间短。

CMS收集器的内存回收过程是与用户线程一起并发执行的:

       CMS收集器对CPU资源非常敏感。 其实, 面向并发设计的程序都对CPU资源比较敏感。在并发阶段, 它虽然不会导致用户线程停顿, 但是会因为占用了一部分线程( 或者说CPU资源) 而导致应用程序变慢, 总吞吐量会降低。CMS是一款基于“标记—清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。 空间碎片过多时, 将会给大对象分配带来很大麻烦, 往往会出现老年代还有很大空间剩余, 但是无法找到足够大的连续空间来分配当前对象, 不得不提前触发一次FullGC。

3.3.7、G1收集器

        G1( Garbage-First) 收集器是当今收集器技术发展的最前沿成果之一。

并行与并发: G1能充分利用多CPU、 多核环境下的硬件优势, 使用多个CPU( CPU或者CPU核心) 来缩短Stop-The-World停顿的时间, 部分其他收集器原本需要停顿Java线程执行的GC动作, G1收集器仍然可以通过并发的方式让Java程序继续执行。

分代收集: 与其他收集器一样, 分代概念在G1中依然得以保留。 虽然G1可以不需要其他收集器配合就能独立管理整个GC堆, 但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、 熬过多次GC的旧对象以获取更好的收集效果。

空间整合: 与CMS的“标记—清理”算法不同, G1从整体来看是基于“标记—整理”算法实现的收集器, 从局部( 两个Region之间) 上来看是基于“复制”算法实现的, 但无论如何, 这两种算法都意味着G1运作期间不会产生内存空间碎片, 收集后能提供规整的可用内存。 这种特性有利于程序长时间运行, 分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。

可预测的停顿: 这是G1相对于CMS的另一大优势, 降低停顿时间是G1和CMS共同的关注点, 但G1除了追求低停顿外, 还能建立可预测的停顿时间模型, 能让使用者明确指定在一个长度为M毫秒的时间片段内, 消耗在垃圾收集上的时间不得超过N毫秒, 这几乎已经是实时Java( RTSJ) 的垃圾收集器的特征了。是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。 G1跟踪各个Region里面的垃圾堆积的价值大小( 回收所获得的空间大小以及回收所需时间的经验值) , 在后台维护一个优先列表, 每次根据允许的收集时间, 优先回收价值最大的Region( 这也就是Garbage-First名称的来由) 。 这种使用Region划分内存空间以及有优先级的区域回收方式, 保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

        G1收集器的运作大致可划分为以下几个步骤:初始标记( Initial Marking)、并发标记( Concurrent Marking)、最终标记( Final Marking)、筛选回收( Live Data Counting and Evacuation初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象, 并且修改TAMS( Next Top at Mark Start) 的值, 让下一阶段用户程序并发运行时, 能在正确可用的Region中创建新对象, 这阶段需要停顿线程, 但耗时很短。 并发标记阶段是从GC Root开始对堆中对象进行可达性分析, 找出存活的对象, 这阶段耗时较长, 但可与用户程序并发执行。 而最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录, 虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面, 最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中, 这阶段需要停顿线程, 但是可并行执行。 最后在筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这个阶段其实也可以做到与用户程序一起并发执行, 但是因为只回收一部分Region, 时间是用户可控制的, 而且停顿用户线程将大幅提高收集效率。

3.4、内存分配与回收策略

新生代GC( Minor GC : 指发生在新生代的垃圾收集动作, 因为Java对象大多都具备朝生夕灭的特性, 所以Minor GC非常频繁, 一般回收速度也比较快。

老年代GC( Major GC/Full GC: 指发生在老年代的GC, 出现了Major GC, 经常会伴随至少一次的Minor GC( 但非绝对的, 在Parallel Scavenge收集器的收集策略里就有直进行Major GC的策略选择过程) 。 Major GC的速度一般会比Minor GC慢10倍以上。

  1. 对象优先在Eden分配:大多数情况下, 对象在新生代Eden区中分配。 当Eden区没有足够空间进行分配时, 虚拟机将发起一次Minor GC。
  2. 大对象直接进入老年代:大对象是指需要大量连续内存空间的Java对象, 最典型的大对象就是那种很长的字符串以及数组。经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。
  3. 长期存活的对象将进入老年代:既然虚拟机采用了分代收集的思想来管理内存, 那么内存回收时就必须能识别哪些对象应放在新生代, 哪些对象应放在老年代中。 为了做到这点, 虚拟机给每个对象定义了一个对象年龄( Age) 计数器。 如果对象在Eden出生并经过第一次Minor GC后仍然存活, 并且能被Survivor容纳的话, 将被移动到Survivor空间中, 并且对象年龄设为1。 对象在Survivor区中每“熬过”一次Minor GC, 年龄就增加1岁, 当它的年龄增加到一定程度( 默认为15岁) , 就将会被晋升到老年代中。
  4. 动态对象年龄判定:如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半, 年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
  5. 空间分配担保:在发生Minor GC之前, 虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间, 如果这个条件成立, 那么Minor GC可以确保是安全的。 如果不成立, 则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。 如果允许, 那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小, 如果大于, 将尝试着进行一次Minor GC, 尽管这次Minor GC是有风险的; 如果小于, 或者HandlePromotionFailure设置不允许冒险, 那这时也要改为进行一次Full GC。因为新生代使用复制收集算法, 但为了内存利用率, 只使用其中一个Survivor空间来作为轮换备份, 因此当出现大量对象在MinorGC后仍然存活的情况。就需要老年代进行分配担保, 把Survivor无法容纳的对象直接进入老年代。

猜你喜欢

转载自blog.csdn.net/ZLZ2017/article/details/84933385