前言

本篇博客将结合博主在实际工作中对JVM的认识,以及前一段时间春节大流量下对Java后台服务的一些参数设置的一个总结。如果你对JVM还不熟悉,可以参考博主以前的博客:《对Java内存结构的一点思考和实践》



JVM体系结构

wKiom1ieuq_g3eCNAAOXIoMxwRo250.png

上图,包含了组成JVM的各个要素,下面我们简单先来介绍下它们。

类加载子系统:负责从文件系统或者网络上加载CLASS信息,加载的信息存放在内存空间中的方法区。

方法区:就是存放类信息、常量信息、常量池信息等。

Java堆:在JVM启动的时候会建立Java堆,Java堆是Java程序最主要的内存区域,也是JVM进行垃圾回收的核心区域,几乎所有的对象都放置在Java堆中,并且堆空间是所有线程共享的。

Java栈:每个虚拟机线程都会有一个私有的栈空间,即Java栈。在Java栈中保存着局部变量、方法参数、方法调用、返回值等信息。总之,这个空间和多线程有关系,和方法的执行有关系。

本地方法栈:要知道一些Java类的实现是依赖于native方法的,也就是通常用C编写的本地方法,本地方法栈和Java栈类似。

垃圾收集系统:Java进行垃圾清理的机制,后文在详细介绍。

PC寄存器:寄存器也是每个线程私有的空间,JVM会为每个线程创建PC寄存器,在任意时刻,一个JAVA线程总是在执行一个方法,即当前线程的当前方法。实际上,如果当前方法不是本地方法,那么会将当前方法的信息存入PC寄存器。在PC寄存器中,实际上,就是一个指针的概念,代表了当前线程的一个执行环境。在多线程进行上下文切换的时候,PC寄存器就发挥作用了。

执行引擎:负责执行虚拟机的字节码。


堆、栈、方法区

可以说,在内存当中,我们最为关心的就是堆、栈、方法区。下面我们重点剖析下,它们三者之间的关系。

wKioL1ie0d7ixOaiAAA1b5S3xxg889.png

比如,有一个User类,有2个实例对象u1/u2,那么存储结构信息就如上图所示。

堆,解决的是数据存储的问题,即数据怎么放,放在哪里。即User类的2个实例对象都存放在堆中。

栈,解决的是程序的运行问题,即程序如何执行。说白了,u1/u2这2个局部变量,对真实对象的引用就存放在栈中。

方法区,是堆、栈的一个辅助区域,或者说是先决条件,没有类信息等,如何创建对象呢。


wKiom1ie1mLhZrw9AAAsvKqAcLI352.png

Java堆可以细分为新生代、老年代。其中新生代存放新生的对象或者年龄不大的对象;老年代则存放老年对象。新生代分为Eden、S0、S1这三个区域,SO/S1也称为from/to区域。S0/S1这两个区域是大小相等并且可以互换角色的空间,在后文的复制回收算法中在详细描述它们。

在绝大多数情况下,对象首先分配在Eden区域,在一次新生代回收后,如果对象还存活,则会进入S0/S1区域,之后每经过一次新生代回收,如果对象存活,它的年龄就加一,当年龄达到阀值后,就会进入老年代。


Java栈的结构

wKiom1ie2aqRhIoWAABYT4VAuJg941.png


Java方法区,也称之为永久区(Perm)。方法区,它保存系统的类信息,比如类的字段、方法、常量池等信息。方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,而方法区空间不足,就有可能抛出内存溢出错误。



JVM参数设置


-XX:+PrintGC                   使用这个参数,虚拟机启动后,只要遇到GC,就会打印日志        

-XX:+PrintGCDetails            可以查看详细信息,包括各个区的情况

-XX:+PrintCommandLineFlags     将给虚拟机设置的参数全部打印出来


-Xms200m                       设置JAVA程序启动时初始堆大小

-Xmx500m                       设置JAVA程序可以获得的最大堆大小

注意:在实际开发中,我们一般情况下,都会将堆的初始值和最大值设置成一样的。这样的好处在于减少程序运行时的垃圾回收次数,从而提高性能。


-Xmn70m                        设置新生代的大小

注意:如果设置一个比较大的新生代,那么势必减少老年代的大小,所以这个参数对系统性能以及GC行为有很大影响。在实际中,一般将新生代大小设置为整个堆大小的1/3到1/4左右。也就是说新生代:老年代=1/2 到 1/3。总而言之,我们应该尽可能将对象预留在新生代,减少老年代的GC次数。


-XX:SurvivorRatio              用于新生代中Eden/from=Eden/to的比例 


-XX:+HeapDumpOnOutOfMemoryError 

-XX:HeapDumpPath=/log/xxx.dump   

开启堆内存溢出OOM错误导出堆信息功能,并打印到指定路径下

注意:实际上这个堆内存导出文件,可以用相关内存分析工具进行分析。


-Xss1m                         线程的最大栈空间

注意:这个参数直接决定了方法可以调用的最大深度,特别是递归调用。


-XX:PermSize=64M 

-XX:MaxPermSize=64M            方法区的初始、最大大小设置


-XX:MaxDirectMemorySize        

注意:这个参数用于配置直接内存。什么是直接内存,想一想NIO,你就会明白。如果不设置,默认就是最大堆大小。因此如果直接内存如果没有有效释放空间,或者达到堆空间的最大大小,就会OOM。


-client/-server                事实上,在JDK1.7 64以后,就已经没有这方面的事情了。


-XX:MaxTenuringThreshold=15    设置新生代对象进入老年代的对象的年龄阀值,默认就是15次


-XX:PretenureSizeThreshold     

如果对象的大小比较大,无法在Eden直接分配呢?会直接进入老年代!但是需要注意TLAB的优先分配。


-XX:+UseTLAB

-XX:+PrintTLAB

-XX:+TLABSize

使用、打印、设置TLAB大小。后文会介绍TLAB。


-XX:UseSerialGC                 设置新生代、老年代使用串行回收器


-XX:UseParNewGC                 

-XX:ParallelGCThreads

设置新生代ParNew回收器,以及回收线程的个数


-XX:UseParallelOldGC            


CMS相关参数后文介绍。



GC


垃圾收集算法


引用计数法

一句话,如果对象存在被引用,则计数器加一,失效则减一;如果计数器为0,就可以回收。

无法解决循环引用的问题,而且计数器经常加减,操作频繁,性能不佳。实际工作中并没有使用。


标记清除法

分为标记、清除阶段。那么怎么标记呢?并不是采用上面的引用计数法,而是基于ROOT树寻找路径来确定是否应该被标记。被标记将被清除,这会导致空间碎片的问题。垃圾回收后的空间不是连续的,显然不连续的内存空间的效率要低于连续内存空间的效率。


复制算法

核心思想就是将内存空间分为2块,每次只是用其中一块。在垃圾回收时,将其中一块A的无法回收的留存对象全部COPY到另一块B内存中,然后清除块A内存中的所有对象。垃圾回收时,反复去交换这2块空间的角色,完成垃圾收集工作。这里,显然避免了内存碎片的问题,但是需要注意的是复制的成本。如果有很多对象需要复制呢?我们知道新生代的很多对象很不稳定,是会被频繁回收的,因此对于新生代而言,这种算法的复制成本比较小,所以被广泛应用。相比老年代而言,大量的对象是稳定的,甚至是不会被回收的,因此复制成本太大了,不适合。


标记压缩法

在标记清除算法的基础上做了些改进,就是把存活的对象压缩到内存的另一端,而后进行垃圾清理,从而避免了内存碎片的问题。事实上,老年代采用的就是这种算法。


其实,为什么分为新生代、老年代?说白了,就是想根据对象的特点进行分类,不同的特点就可以使用不同的算法,这就是分代的好处。对于新生代、老年代而言,新生代回收频率很高,但是每次回收耗时很短;而老年代回收频率很低,耗时会相对较长,所以应该尽量减少老年代的GC。


停顿现象

垃圾回收的任务就是识别和回收垃圾对象,完成内存清理动作。为了让GC高效的执行,大部分情况下,会要求系统进入一个停顿的状态,也就是暂停了所有的应用线程。因为只有这样才不会有新的垃圾产生,同时停顿也保证了系统在某一瞬间的一致性,有益于垃圾的标记。因此在进行GC的时候,会产生应用程序的停顿。


TLAB

Thread Local Allocate Buffer,线程本地分配缓存,一个线程专用的内存分配区域。在实际中,每个线程势必会用到内存,为什么不提前就为每个线程创建一个较小的专属的内存区域呢?不必等到线程使用到内存的时候在去申请。这样的话,会加速线程的运行速度,而且也避免了多线程的问题。当然,TLAB区域并不会太大。



垃圾收集器

wKioL1ifxqaQzYIiAADf33N-IsA143.png


串行回收器

所谓串行,就是单线程进行垃圾回收工作。新生代、老年代都可以使用。如果机器的并行性能比较差,可以考虑这种,因为它的专注性、独占性会有不错的表现。


并行回收器:ParNew

在串行回收器的基础上进行功能增强,就是使用多线程。适用于新生代。显然,对于并行能力较强的计算机而言,会有效缩短垃圾回收的实际时间。


并行回收器:ParallelGC

多线程独占+复制算法,新生代回收,非常关注系统的吞吐量,因为它提供参数来进行吞吐量的控制,比如设置最大垃圾收集停顿时间-XX:MaxGCPauseMillis。


并行回收器:ParallelOldGC

多线程独占+标记压缩算法,老年代回收。


目前最主流的回收器:CMS

CMS,即Concurrent Mark Sweep,并发标记清除,采用标记算法,适用老年代,关注系统停顿时间。

需要注意的是,CMS并不是独占的回收器,也就是说CMS回收过程中,应用程序仍然在不断工作,这是CMS回收器的一大优点。也正因为如此,CMS回收,应用程序不停顿运行,垃圾继续会产生,需要足够的内存空间。CMS并不会等到应用程序饱和才开始回收,而是根据指定的阀值开始回收。比如-XX:CMSInitiatingOccupancyFration设置为68,就是说当老年代空间使用达到68%的时候,CMS开始回收。另外,CMS为了避免标记算法产生的内存碎片问题,提供了功能,比如-XX:+UseCMSCompactAtFullCollection可以让CMS回收一次后进行内存碎片整理,-XX:CMSFullGCBeforeCompaction参数可以设置多少次CMS回收后,对内存进行一次压缩整理


G1回收器

目前应用并不广泛。其主要的思想在于分区算法,把内存区域划分为N多个小的独立的空间,实际上是想细粒度的对内存进行划分,这样就可以细粒度的回收,而不是对整个空间进行垃圾回收,从而提升性能,并减少了GC停顿时间。


到这里,JVM总结就结束了,希望对你有用吧~