美文网首页
java大厂面试题整理(八)JVM内存溢出和垃圾回收机制

java大厂面试题整理(八)JVM内存溢出和垃圾回收机制

作者: 唯有努力不欺人丶 | 来源:发表于2021-05-11 21:14 被阅读0次

    元空间概念

    其实说到这还是要简单说下java8、虽然是版本迭代,但是JAVA8相对于之前来说是个大版本的迭代,改了很多东西。首先,在Java8中,永久代已经被移除,被一个称为元空间的取间所取代。元空间的本质和永久代类似。
    元空间与永久代最大的区别在于:永久代使用的JVM的堆内存。但是Java8以后的元空间并不在虚拟机中而是使用本机物理内存。
    因此,默认情况下,元空间的大小仅仅受本地内存限制。类的元数据放入native memory,字符串池和类的静态变量放入java堆中,这样可以加载多少类的元数据就不再由MaxPermSize控制,而是由系统的实际可用空间来控制。


    其实JVM的报错还是有挺多的,下面我们一个个介绍:

    java.lang.StackOverflowError

    这个是栈异常。而栈是存方法区的。显示这个异常很容易:死递归就可以了:


    栈异常demo

    我们思考一下这个到底是异常还是错误:虽然口语上我们一般都说报错了,但是其实本质上java中的报错分两种:Exception异常和Error错误。
    而几乎后缀带Error的都是错误。后缀的Exception的都是异常。

    java.lang.OutOfMemoryError:java.heap.spack

    这个错误其实也比较容易理解。如其名内存溢出:堆空间。
    而如何实现堆的溢出呢?这个其实我们上面也测试过。测试弱引用和软引用gc 的时候创建了大对象。


    堆溢出

    java.lang.OutOfMemoryError:GC overhead limit exceeded

    这个错误的中文翻译:超出GC开销限制。
    就是说某一个时刻,GC回收时间过长会抛出这个异常。过长的指:超过百分之九十八的时间用来做GC,并且回收了不到百分之二的堆内存。连续GC多次都只回收了不到百分之二的极端情况才会抛出这个异常。假如不抛出这个异常会产生的情况:很快内存满了,继续GC,GC又收不到东西,然后又很快满,再GC。。。如此恶行循环下去。所以才会有这个错误。下面是代码的测试:

        public static void main(String[] args) throws Exception {
            List<String> list = new ArrayList<String>();
            int i = 0;
    
            try {
                while (true) {
                    list.add(String.valueOf(i++));
                }
            } catch (Exception e) {
                System.out.println(i);
                e.printStackTrace();
                // TODO: handle exception
            }
        }
    

    java.lang.OutOfMemoryError:Direct buffer memory

    这个错误指直接内存挂了。元空间并不在虚拟机中,而是使用本地内存。理论上大小仅受硬件大小限制。而这个错误的原因是指硬件内存受限了。简而言之总结为:jvm好好的,本地的内存用光了,导致程序崩溃。
    我们常用的i/o ByteBuffer有两个方法:
    ByteBuffer.allocate()是分配JVM堆内存。属于GC管辖范围。由于需要拷贝所以速度相对较慢。
    ByteBuffer.allocateDirect()是分配os的本地内存,不属于GC管辖范围。由于不需要内存拷贝所以速度相对较快。
    而这个报错就是指物理内存的崩盘,也就是一直用allocateDirect不断创建对象。如下代码:


    Direct buffer memory

    上图第一个打印语句是查询当前可用物理内存多大。我这边打印是5.5M.所以我创建一个6M的对象就直接报错了。(这里物理内存的大小最好调小一点。不然不容易出效果)

    java.lang.OutOfMemoryError:unable to create new native thread

    这个错误的字面意思:不能再创建更多新的本地线程了。
    首先这个错一般都是高并发的时候报出来的,导致原因:

    1. 一个应用进程创建了太多的线程。超过系统承载极限。
    2. 服务器并不允许你的应用程序创建这么多线程。linux默认允许单个进程创建的线程数是1024.

    解决办法:

    1. 降低应用的线程数量。
    2. 修改linux的默认配置。

    java.lang.OutOfMemoryError:Metaspace

    这个也比较好理解:元空间溢出。元空间是方法区。它与永久代最大的区别就是它在本地内存而不是虚拟机内存中。下面是demo:

    public class OOMTest {
    
        public static void test() {
            
        }
        public static void main(String[] args){
            int i = 0;
            try {
                while(true) {
                    Enhancer enhancer = new Enhancer();
                    enhancer.setSuperclass(OOMTest.class);
                    enhancer.setUseCache(false);
                    enhancer.setCallback(new org.springframework.cglib.proxy.MethodInterceptor() {                  
                        @Override
                        public Object intercept(Object arg0, Method arg1, Object[] arg2, MethodProxy arg3) throws Throwable {
                            return arg3.invokeSuper(0, args);
                        }
                    });
                    enhancer.create();
                    i++;
                }
            } catch (Exception e) {
                System.out.println(i);
                e.printStackTrace();
            }
        }
    
    }
    

    利用反射不断创建类。然后就爆了。需要注意的是这个默认的元空间大小比较大,想要跑出效果一定要实现把元空间和最大元空间设置小点,如下参数:

    -XX:MetaspaceSize=5m -XX:MaxMetaspaceSize=5m
    
    我的设置

    以上就是JVM常见报的错误!下面开始简单的说一下垃圾回收算法和垃圾回收器。

    垃圾回收

    首先GC算法(引用计数/复制/标记清除/标记整理)是内存回收的方法论,而垃圾收集器是算法的落地实现。
    目前为止还没有完美的收集器出现,更加没有万能的收集器,只能根据具体应用选择最合适的收集器。
    目前主要有四种垃圾收集器(java10以前的,java11以及以后出来了个新的ZGC。不过因为比较新,所以先不谈了):

    • 串行垃圾回收器(Serial)
      它为单线程环境设计且只使用一个线程进行垃圾回收。会暂停所有的用户线程,所以不适合服务器环境。
    • 并行垃圾回收器(Parallel)
      多个垃圾收集线程并行工作。此时用户线程也是暂停的,适用于科学计算/大数据处理等弱交互场景。
      串行和并行都需要暂停用户线程,也叫STW(stop the world)。
    • 并发垃圾回收器(CMS)
      用户线程和垃圾收集线程同时执行(不一定是并行,可能是交替执行)不需要停顿用户线程。互联网公司多用它,适用对响应时间有要求的场景。
    • G1垃圾回收器
      历经了十年的准备,在java8中开始使用。G1垃圾收集器将堆内存分割成不同的区域然后并发的对其进行垃圾回收。

    注意,上面说的是四种垃圾收集器机制,但是落实到实际是有七大垃圾收集器的。

    怎么查看/修改程序的垃圾收集器呢?

    其实这个上篇笔记记到了,有一个jvm的命令可以查看,如下:

    java -XX:+PrintCommandLineFlags -version
    

    这个命令是查看一些比较重要参数的,结果如下图:


    默认并行垃圾回收

    如图所示,默认的是并行垃圾回收器。当然了我们也可以去修改这个默认。修改方法就是单纯的修改jvm参数,也没啥好说的。而这个我们可以设置的参数值有六种(注意上面说有七种垃圾收集器,这里只有六种的原因:有个serialOldGC,但是因为现在被废弃了,都没人用了。):

    • UserSerialGC
    • UseParallelGC
    • UseConcMarkSweepGC
    • UseParNewGC
    • UseParallelOldGC
    • UseG1GC

    下面我们实际测试一下:
    首先在启动的时候添加参数,修改垃圾收集器:


    默认是并行

    然后我们在控制台查看这个线程:


    查看结果
    加号代表的是启动,减号代表未启用。所以说这个参数是起效果了的。

    七大垃圾收集器

    重复一遍:这块的概念比较容易混淆,最上面说的四大垃圾收集器我们可以理解为按照垃圾收集器的原理划分的。
    而六种垃圾收集器可设置参数是因为其中串行老年代这种垃圾收集器已经被废弃了,所以可设置的参数就只有六种了。
    而这里将的七大垃圾收集器是指java中出现过的垃圾收集器,包括已经废弃了的那个,继续往下说。垃圾收集器分两种,这个也是java的内存结构决定的、因为分为年轻代和老年代。年轻代朝生夕死,迭代比较快,老年代一般都是比较稳定的或者大对象什么的。所以针对这两个区域我们要采取的垃圾收集方法也并不一样。所以同理针对不同区垃圾收集器也是不同的。下面是一张图来粗略的看下垃圾收集器的情况:


    垃圾收集器
    垃圾收集器搭配

    其实如其名:我们之前的参数就可以看出来:old一般都是用在老年区的。cms也是用在老年区的。new就是用在年轻代的。而G1比较特殊,它是既可以用在老年代,也可以用在年轻代的。

    JVM的Server和Client模式
    首先在生产环境下,一般都使用Server模式,Client模式基本不会用。二者的区别:

    • 32位window系统,无论硬件如何都默认使用Client的JVM模式
    • 32位其它操作系统,2G内存同时有2个CPU以上用Server模式,低于该配置还是Client模式。
    • 64位只能是Server模式。(做开发一定用64位的!!!)

    下面继续说垃圾回收的选择,其实因为垃圾收集器除了G1以外都是一对一对的,所以有时候我们选择了新生代,老年代会自动选择其匹配的。也就是新生代的垃圾收集器选择很重要。

    新生代可选的垃圾收集器(不考虑G1):
    • 串行GC(Serial)
      它是最古老,最稳定,效率最高的收集器。坏处就是收集过程中需要暂停其他工作线程。对于限定单核CPU的环境下(现在怎么可能有单核的服务器!所以这个收集器也老了),没有线程交互的开销可以获得最高的收集效率,因此Serial垃圾收集器是Client模式(低配才会是Client模式)下默认的新生代垃圾收集器。
      开启这个参数UseSerialGC以后,会默认采用Serial/SerialOld两种垃圾收集器.
    • 并行GC(ParNew)
      一句话:使用多线程进行垃圾回收,在垃圾回收的时候也会STW直到它收集结束。
      ParNew其实是Serial收集器新生代的并行多线程版本。最常见的就是配合老年代的CMS 工作,其余的行为了Serial收集器完全一样。它是很多java虚拟机在Server模式新生代的默认收集器。
      这个开启这个垃圾回收器的参数-XX:+UseParNewGC,启用ParNew收集器,只影响新生代,不影响老年代。(注意ParNew+SerialOld联合使用会JVM报warn,不推荐)。
      另外这个ParNew是可以设置垃圾收集器并行的最大线程数的。
    • 并行回收GC(Parallel Scavenge)
      注意,现在常用的默认的垃圾收集器不是上面的并行GC,而是更加进步了,变成了默认是UseParallelGC。这个代表的是ParallelScavenge。
      它是类似ParNew的一个新生代垃圾收集器,使用复制算法,也是一个并行的多线程的垃圾收集器。俗称吞吐量优先收集器。并行收集器就是串行收集器在新生代和老年代的并行化。
      它的重点关注是:
      可控制的吞吐量。吞吐量计算公式= 代码运行时间/(代码运行时间+垃圾回收时间)。高吞吐量意味着高效利用CPU时间。
      自适应调节策略(相比于ParNew的重要区别)。虚拟机根据当前系统的运算情况收集性能监控信息。动态的调整这些参数以提供最合适的停顿时间(-XX:MaxGCPauseMillis)或最大吞吐量。
      这里有个很重点的事项:Parallel和ParallelOld可以互相激活!也就是设置其中一个另一个也会相应生效。

    同样这个也是可以设置GC收集器的最大线程数。参数如下:
    -XX:ParallelGCThreads = N.表示启动N个GC线程。
    这里有个建议:

    • CPU>8, N = 5/8
    • CPU<8, N = cpu个数
    老年代可选的垃圾收集器:
    • 串行GC
      jdk1.6之前默认老年代使用SerialOld。这个没啥说的,和Serial是一样的,只不过是用在老年代而已。而且是CMS收集器的后备收集器。
    • 并行GC
      这个垃圾收集器和版本关系很大。JDK1.6之前新生代用ParallelScavenge老年代用SerialOld
      ParallelOld是JDK1.6出现的
      JDK1.8及以后默认是ParallelScavenge+ParallelOld
    • 并发标记清除GC(CMS)
      这个也是比较经典的有个垃圾回收算法的落地实现了。标记清除上文提过了。先标记,再统一清除。好处是速度快,坏处是会产生内存碎片。
      这种方式非常适合应用在互联网或者B/S系统的服务器上。这类应用尤其重视服务器响应速度,希望停顿时间最短。
      CMS非常适合堆内存大,CPU核数多的服务器上,也是G1出现之前大型应用的首选收集器。
      如果采用这个老年代收集器,会自动将ParNew收集器打开。
      开启该参数后,会使用ParNew+CMS+SerialOld(作为CMS出错的后备收集器)组合。
      CMS的标记清除分为四步:
    1. 初始标记:标记GC Roots能直接关联的对象,速度很快,但需要暂停所有工作线程。
    2. 并发标记:进行GC Roots的跟踪过程,和用户线程一起工作,主要标记过程,标记全部对象。
    3. 重新标记:修正并发标记期间,因为程序继续运行而导致标记变动的那一部分的标记记录。需要暂停所有工作线程。
    4. 并发清除:清除GC Roots不可达对象。和用户线程一起工作。不需要暂停工作线程,基于标记结果清理对象。

    和一次STW相比,因为并发和清除是最耗时的,都可以和用户线程一起用。所以感觉上停顿时间较短。这也是CMS最大的优点:并发收集停顿低。
    而CMS的缺点也比较有意思:
    由于并发执行,CMS在收集时与应用线程同时工作,会增加对堆内存的占用。也就是说:CMS必须要在老年代堆内存用尽之前完成垃圾回收,否则CMS会回收失败。回收失败时会触发担保机制:也就是之间说的SerialOld。串行老年代收集器会以STW的方式进行一次GC从而造成较大的停顿时间。
    同时因为CMS采用的是标记清除,所以会产生大量的内存碎片。

    如何选择合适的垃圾收集器呢?

    我们知道了各种垃圾收集器的优缺点和运行机制,但是工作中如何选择合适的垃圾收集组合呢?

    • 单CPU或小内存,单机程序 选择-XX:+UseSerialGC
    • 多CPU,需要大量吞吐计算,如后台计算型应用,选择 -XX:+UseParallelGC(-XX:+UseParallelOldGC也可以,这两者互相启动的)
    • 多CPU,追求低停顿时间,需要快速响应如互联网应用,选择
      -XX:+UseConcMarkSweepGC和-XX:+ParNewGC


      各个收集器使用的算法

    G1垃圾收集器

    G1不同于上面说的那六种收集器。而是全新的一种模式。以前的收集器有以下特点:

    1. 年轻代和老年代是各自独立且连续的内存块。
    2. 年轻代收集使用eden+from+to进行复制算法
    3. 老年代手机必须扫描整个老年代区域。
    4. 都是以尽可能少而快速的执行GC为设计原则。

    而G1是一款面向服务端应用的收集器。应用在处理多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能的满足收集器暂停时间的要求。它的特性:

    • 和CMS一样可以与应用线程并发执行。
    • 整理空闲空间更快。
    • 需要更多的时间来预测GC停顿时间。
    • 不希望牺牲大量的吞吐性能。
    • 不需要更大的Java heap。

    G1收集器的设计目标就是取代DMS收集器。它与CMS相比因为是标整,不会产生很多内存碎片,而且STW更可控,G1的停顿时间是有预测机制的,所以用户可以指定期望停顿时间。
    G1是2012年,jdk1.7u4版本出现的。而jdk9中,G1变成了默认的垃圾收集器代替了CMS。G1的特点:

    1. G1充分利用多CPU,多核环境硬件优势,尽量缩短STW。
    2. G1整体上采用标记整理算法,局部是通过复制算法,不会产生内存碎片。
    3. 宏观上看G1之中不再区分年轻代和老年代,把内存划分成多个独立的子区域(Region)。可以近似理解为一个魔方面或者棋盘。
    4. G1收集器里面将整个内存都混合在一起了。但其本身依然在小范围内要进行年轻代和老年代的区分。保留了新生代和老年代。但它们不再是物理隔离的,而是一部分子区域的集合且不要求是连续的。也就是说依然会采用不同的GC方式来处理不同区域。
    5. G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代和老年代的区别。也不需要完全独立的survivor堆做复制准备。G1只有逻辑上的分代概念。或者说每个分区都可能随着G1的运动在不同代之间切换。
    G1的底层原理

    区域化内存划片Region。整体编为了一些不连续的内存区域,避免了全内存区的GC操作。
    核心思想是将整个堆内存区域分乘大小相同的子区域,在JVM启动时会自动设置这些子区域的大小。
    G1并不要求对象的存储一定是物理上连续的。只要逻辑上连续就可以。每个分区也不会固定为某个代服务。可以按需切换为年轻代或者老念代。
    Region的大小在1M-32M之间。最多能设置2048个区。所以能支持的内存大小最大为322048 = 64G.*

    G1运行原理 G1运行原理

    一句话总结G1:区域化管理,最大的好处是化整为零,避免全内存扫描,只需要按照区域来进行扫描即可。

    G1回收步骤

    针对eden区进行收集,eden区耗尽会触发,主要是小区域收集+形成连续的内存块。避免内存碎片。

    1. eden区的数据移动到Survivor。如果Survivor区空间不够,eden区数据会晋升到old区。
    2. Survivor区的数据移动到新的Survivor区。部分数据晋升到old区。
    3. 最后eden区收拾干净了,GC结束,用户的应用程序继续执行。
    G1参数配置

    G1有很多特有的参数,常用的几个如下:

    1. -XX:G1HeapRefionSize=n.设置G1区域大小,范围是1-32m。
    2. -XX:MaxGCPauseMillis=n.最大GC停顿时间。JVM尽可能但不保证小于这个时间。
    3. -XX:InitiatingHeapOccupancyPercent=n.堆占用多少的时候触发GC,默认是45
    4. -XX:ConcGCThreads=n.并发GC使用的线程数
    5. -XX:G1ReservePercent=n.设置作为空闲空间的预留内存百分比。默认是百分之十。

    本篇笔记就记到这里,如果稍微帮到你了记得点个喜欢点个关注。也祝大家工作顺顺利利,身体健康!愿所有的努力都不会被辜负!

    相关文章

      网友评论

          本文标题:java大厂面试题整理(八)JVM内存溢出和垃圾回收机制

          本文链接:https://www.haomeiwen.com/subject/umuprltx.html