JVM基础

目录结构
  1. 内存模型
  2. 如何保证内存可见性
  3. 如何保证CPU缓存一致性
  4. 类加载和双亲委派
  5. GC垃圾回收:包括分代、GC算法、收集器
  6. JVM调优
  7. 内存泄漏和内存溢出
  8. 四种引用类型

内存模型( Java Memory Model )
  1. 什么是JMM
  • JMM控制Java线程之间通信,JMM决定一个线程对共享变量的写入何时对另一个线程可见。
  • 从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。
  • 本地内存是JMM的一个抽象概念,并不真实存在。
  • Java内存模型的抽象示意图如下
    JVM基础_第1张图片
  • 例子解释JMM
    JVM基础_第2张图片

[面试题: 内存可见性]
  • 问题:通过禁止重排序。在程序执行过程中为了提高性能,编译器和处理器会对指令做重排序操作,但是在多线程时会导致内存可见性问题。
  • 解决:JMM通过内存屏障方法保证程序在不同编辑器和CPU下得到相同的执行结果。通过插入特定的内存屏障来禁止特定类型的编辑器和处理器的重排序,插入一条内存屏障会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。

[面试题: 缓存一致性]

保证各个CPU的缓存一致性,每个CPU通过嗅探在总线上传播的数据来检查自己缓存的数据有效性,当发现自己缓存行对应的内存地址的数据被修改,就会将该缓存行设置成无效状态,当CPU读取该变量时,发现所在的缓存行被设置为无效,就会重新从主内存中读取数据到缓存中。

JVM内结构图
JVM基础_第3张图片


类加载、双亲委派
  1. 类加载过程:
    JVM基础_第4张图片
  • 加载:

    1. 根据一个类的全限定名(如cn.edu.hdu.test.HelloWorld.class)来读取此类的二进制字节流到JVM内部;
    2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构(
    3. 转换为一个与目标类型对应的java.lang.Class对象;
  • 验证:
    验证阶段主要包括四个检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证;

  • 准备:
    为类中的所有静态变量分配内存空间,并为其设置一个初始值(由于还没有产生对象,实例变量将不再此操作范围内);

  • 解析:
    将常量池中所有的符号引用转为直接引用(得到类或者字段、方法在内存中的指针或者偏移量,以便直接调用该方法)

  • 初始化【何时触发初始化】:
    (1) 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。
    生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候,读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
    (2) 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
    (3) 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
    (4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

  • 使用

  • 卸载

  1. JVM预定义的三种类型类加载器:
  • 启动(Bootstrap)类加载器:它负责将 /lib下面的类库加载到内存中(比如rt.jar)。
  • 标准扩展(Extension)类加载器:它负责将< Java_Runtime_Home >/lib/ext或者由系统变量 java.ext.dir指定位置中的类库加载到内存中。
  • 系统(System)类加载器:它负责将系统类路径中指定的类库加载到内存中。开发者可以直接使用系统类加载器。
  1. 双亲委派机制
    JVM基础_第5张图片
  • [面试题描述双亲委派机制]
    1. 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
    2. 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
    3. 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载。
  • 为什么使用双亲委派机制
    (1)采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级层次关系,通过这种层级关可以避免类的重复加载,当父亲加载器已经加载了该类时,子类加载器就不需要再加载一次。不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个Object对象。

GC垃圾回收
  1. 内存区域结构

  • (1) 堆内存结构

    • 堆是多线程共享的内存空间,在JVM启动时,存储类的实例对象和new 数组。
    • 设置堆内存大小参数:-Xms-Xmx
      -Xms为JVM启动时申请的最小内存,默认为操作系统物理内存的1/64但小于1G,-Xmx为JVM可申请的最大内存,默认为物理内存的1/4但小于1G。
      默认当空余堆内存小于40%时,JVM会增大Heap到-Xmx指定的大小,可通过-XX:MinHeapFreeRation 来指定这个比列;当空余堆内存大于70%时,JVM会减小heap的大小到-Xms指定的大小,可通过XX:MaxHeapFreeRation来指定这个比例,对于运行系统,为避免在运行时频繁调整Heap的大小,通常-Xms与-Xmx的值设成一样。
    • 堆内存结构主要分为新生代、老年代。
    • [面试题: 为何要分代]
      堆内存分代可以提高对象内存分配和垃圾回收的效率。如果堆内存没有区域划分,所有的新创建的对象和生命周期很长的对象放在一起,随着程序的执行,堆内存需要频繁进行垃圾收集,而每次回收都要遍历所有的对象,遍历这些对象所花费的时间代价是巨大的,会严重影响我们的GC效率。
      有了内存分代,情况就不同了,新创建的对象会在新生代中分配内存,经过多次回收仍然存活下来的对象存放在老年代中,静态属性、类信息等存放在永久代中,新生代中的对象存活时间短,只需要在新生代区域中频繁进行GC,老年代中对象生命周期长,内存回收的频率相对较低,不需要频繁进行回收,永久代中回收效果太差,一般不进行垃圾回收,还可以根据不同年代的特点采用合适的垃圾收集算法。分代收集大大提升了收集效率,这些都是内存分代带来的好处。
      JVM基础_第6张图片
      (2) 新生代
    • 新生代分为三部分:Eden、From service、To service比例为8:1:1。JVM启动时,类对象存储在Eden和From Service内存中,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。会将Eden和From service内存中存活下来的对象存储到To service中,清除Eden和From service内存; Minor GC时当To Survivor区没有足够的空间存放上一次新生代收集下来的存活对象时,需要依赖老年代进行分配担保,将这些对象存放在老年代中。
      (3) 老年代
      用于存放经过多次新生代GC依然存活的对象,老年代中的对象生命周期较长,存活率比较高,在老年代中进行GC的频率相对而言较低,而且回收的速度也比较慢。老年代主要存储的是大对象、大数组对象。
      (4) JDK1.8取消永久代
  • 方法区
    方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

  • 虚拟机栈
    与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

  • 本地方法栈
    本地方法栈则是为虚拟机使用到的Native方法服务。

  • 程序计数器PC
    它的作用可以看做是当前线程所执行的字节码的行号指示器。

  1. Minor GC流程
    (1)绝大多数刚创建的对象会被分配在Eden区,其中的大多数对象很快就会消亡。Eden区是连续的内存空间,因此在其上分配内存极快;
    (2)当Eden区满的时候,执行Minor GC,将消亡的对象清理掉,并将剩余的对象复制到一个存活区Survivor0(此时,Survivor1是空白的,两个Survivor总有一个是空白的);
    (3)下次Eden区满了,再执行一次Minor GC,将消亡的对象清理掉,将存活的对象复制到Survivor1中,然后清空Eden区;
    (4)将Survivor0中消亡的对象清理掉,将其中可以晋级的对象晋级到Old区,将存活的对象也复制到Survivor1区,然后清空Survivor0区;
    (5)当两个存活区切换了几次(HotSpot虚拟机默认15次,用-XX:MaxTenuringThreshold控制,大于该值进入老年代,但这只是个最大值,并不代表一定是这个值)之后,仍然存活的对象,将被复制到老年代。

  2. Full GC触发条件
    (1)调用System.gc时,系统建议执行Full GC,但是不必然执行
    (2)老年代空间不足
    (3)方法去空间不足
    (4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存
    (5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

  3. 对象存活判断
    判断对象是否存活一般有两种方式:

  • 引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题。
    四种引用类型:

    1. 强引用
      new 出来的对象,强引用不会被JVM回收,即使发生OOM异常也不会进行垃圾回收。
    2. 软引用
      在OOM之前会进行回收。
    3. 弱引用
      在无论是否发生OOM异常,都会被JVM回收。
    4. 虚引用
      类似于弱引用,是最弱的引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。设置虚引用的唯一目的就是能在这个对象被垃圾收集器回收时收到一个系统的通知。
  • 可达性分析(Reachability Analysis):从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可达对象。
    在Java语言中,GC Roots对象包括:

    1. 虚拟机栈中引用的对象。
    2. 方法区中类静态属性实体引用的对象。
    3. 方法区中常量引用的对象。
    4. 本地方法栈中JNI引用的对象。

垃圾收集算法(垃圾回收方法论)
  1. 标记清除算法
    “标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

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

  2. Copy算法
    “复制”(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

    这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低。
    JVM基础_第8张图片

  3. 标记压缩算法
    复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

    根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
    JVM基础_第9张图片

  4. 分代算法
    GC分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。

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


垃圾收集器(垃圾回收的具体实现)
  1. Serial收集器
    串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。新生代、老年代使用串行回收;新生代复制算法、老年代标记-压缩;垃圾收集的过程中会Stop The World(服务暂停)

    参数控制: -XX:+UseSerialGC 串行收集器
    JVM基础_第10张图片 ParNew收集器其实就是Serial收集器的多线程版本。新生代并行,老年代串行;新生代复制算法、老年代标记-压缩
    参数控制:
    -XX:+UseParNewGC ParNew收集器
    -XX:ParallelGCThreads 限制线程数量

  2. Parallel收集器
    Parallel Scavenge收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例;新生代复制算法、老年代标记-压缩
    JVM基础_第11张图片
    参数控制: -XX:+UseParallelGC 使用Parallel收集器+ 老年代串行

  3. Parallel Old 收集器
    Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供。
    JVM基础_第12张图片
    参数控制: -XX:+UseParallelOldGC 使用Parallel收集器+ 老年代并行

  4. CMS收集器
    CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
    CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:

    • 初始标记(CMS initial mark)
    • 并发标记(CMS concurrent mark)
    • 重新标记(CMS remark)
    • 并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。

  • 优点: 并发收集、低停顿
  • 缺点:
  1. 产生大量空间碎片,造成了堆空间的浪费以及利用率的下降。
  2. 并发阶段会降低吞吐量(并发时大多数资源被gc使用,用户程序运行空间较少)
  3. 会产生浮动垃圾,由于CMS并发清理阶段用户线程还在运行着,伴随程序自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好等到下一次GC去处理。
  • 参数控制:
    -XX:+UseConcMarkSweepGC 使用CMS收集器
    -XX:+ UseCMSCompactAtFullCollection Full GC后,进行一次碎片整理;整理过程是独占的,会引起停顿时间变长
    -XX:+CMSFullGCsBeforeCompaction 设置进行几次Full GC后,进行一次碎片整理
    -XX:ParallelCMSThreads 设定CMS的线程数量(一般情况约等于可用CPU数量)

JVM基础_第13张图片

  1. G1收集器
    G1是目前技术发展的最前沿成果之一,HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。]
    JVM基础_第14张图片
    与CMS收集器相比G1收集器有以下特点:
    • 空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。
    • 可预测停顿,这是G1的另一大优势,降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

上面提到的垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region的集合。
JVM基础_第15张图片

G1的新生代收集跟ParNew类似,当新生代占用达到一定比例的时候,开始出发收集。和CMS类似,G1收集器收集老年代对象会有短暂停顿。

收集步骤:
1、标记阶段,首先初始标记(Initial-Mark),这个阶段是停顿的(Stop the World Event),并且会触发一次普通Mintor GC。对应GC log:GC pause (young) (inital-mark)
2、Root Region Scanning,程序运行过程中会回收survivor区(存活到老年代),这一过程必须在young GC之前完成。
3、Concurrent Marking,在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那个这个区域会被立即回收(图中打X)。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
JVM基础_第16张图片

4、Remark, 再标记,会有短暂停顿(STW)。再标记阶段是用来收集 并发标记阶段 产生新的垃圾(并发阶段和应用程序一同运行);G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。
5、Copy/Clean up,多线程清除失活对象,会有STW。G1将回收区域的存活对象拷贝到新区域,清除Remember Sets,并发清空回收区域并把它返回到空闲区域链表中。
JVM基础_第17张图片

6、复制/清除过程后。回收区域的活性对象已经被集中回收到深蓝色和深绿色区域。
JVM基础_第18张图片
针对Eden区进行垃圾回收:
JVM基础_第19张图片
JVM基础_第20张图片


JVM调优
  1. 对内存、垃圾回收相关的一些参数进行设置,希望达到下列目标:
  • GC的时间足够的小
  • GC的次数足够的少
  • 发生Full GC的周期足够的长
  1. 前两个目前是相悖的,要想GC时间小必须要一个更小的堆,要保证GC次数足够少,必须保证一个更大的堆,我们只能取其平衡。
    (1)针对 JVM 堆的设置,为了防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间,我们通常把最大、最小设置为相同的值, 一般可以通过-Xms -Xmx限定其最小、最大值
    (2)新生代和老年代将根据默认的比例(1:2)分配堆内存,可以通过调整二者之间的比率 NewRadio 来调整二者之间的大小。同样,为了防止年轻代的堆收缩,我们通常会把-XX:newSize -XX:MaxNewSize设置为同样大小。
    新生代和老年代设置多大才算合理?
    - 更大的新生代必然导致更小的老年代,大的新生代会延长GC的周期,但会增加每次GC的时间;小的年老代会导致更频繁的Full GC。
    - 小的年轻代会导致普通GC很频繁,但每次的GC时间会更短;大的年老代会减少Full GC的频率
    如果应用存在大量的临时对象,应该选择更大的年轻代;如果存在相对较多的持久对象,年老代应该适当增大。在抉择时应该根据以下两点:
    (A)本着Full GC尽量少的原则,让年老代尽量缓存常用对象,JVM的默认比例1:2也是这个道理
    (B)通过观察应用一段时间,看其在峰值时老年代会占多少内存,在不影响Full GC的前提下,根据实际情况加大新生代,比如可以把比例控制在1:1,但应该给年老代至少预留1/3的增长空间。

    (3)在配置较好的机器上(比如多核、大内存),可以为年老代选择并行收集算法: -XX:+UseParallelOldGC ,默认为Serial收集。
    (4)线程堆栈的设置:每个线程默认会开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,对大多数应用而言这个默认值太了,一般256K就足用。理论上,在内存不变的情况下,减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。


内存泄漏和溢出
  • 内存泄漏发生在以下情况:

    1. 使用静态的集合类
      静态的集合类的生命周期和应用程序的生命周期一样长,所以在程序结束前容器中的对象不能被释放,会造成内存泄露。
      解决办法是最好不使用静态的集合类,如果使用的话,在不需要容器时要将其赋值为null。
    2. 单例模式可能会造成内存泄露
      单例模式只允许应用程序存在一个实例对象,并且这个实例对象的生命周期和应用程序的生命周期一样长,如果单例对象中拥有另一个对象的引用的话,这个被引用的对象就不能被及时回收。
      解决办法是单例对象中持有的其他对象使用弱引用,弱引用对象在GC线程工作时,其占用的内存会被回收掉。
    3. 数据库、网络、输入输出流,这些资源没有显示的关闭
      垃圾回收只负责内存回收,如果对象正在使用资源的话,Java虚拟机不能判断这些对象是不是正在进行操作,比如输入输出,也就不能回收这些对象占用的内存,所以在资源使用完后要调用close()方法关闭。
  • 内存溢出情况:

    1. 栈溢出
    2. 堆溢出
    3. 方法区溢出
    4. 本地栈溢出
  • OOM如何解决:
    (1)通过参数 -XX:+HeapDumpOnOutOfMemoryError 让虚拟机在出现OOM异常的时候Dump出内存映像以便于分析。
    (2)通过内存映像分析工具(Eclipse Memory Analyzer)对映像文件进行分析,首先确认内存中的对象是否有必要存活。(到底是出现了内存泄漏还是内存溢出)
    哪些对象被怀疑为内存泄漏,哪些对象占的空间最大及对象的调用关系,还可以分析线程状态,可以观察到线程被阻塞在哪个对象上,从而判断系统的瓶颈。
    (3)如果是内存泄漏,可进一步通过工具查看GC root引用链,找到引用信息,可以准确的定位出内存泄漏的代码位置。(HashMap中的元素的某些属性改变了,影响了hashcode的值会发生内存泄漏)
    (4)如果不存在内存泄漏,就应当检查虚拟机的参数是否可以调大;修改代码逻辑,把某些对象生命周期过长,持有状态时间过长等情况的代码修改。


参考链接
可见性和缓存一致性
Java内存模型
小敏纸-类加载
Java类加载机制
Java虚拟机
JVM内存结构
jvm系列(三):GC算法 垃圾收集器

你可能感兴趣的:(面经,java初级学习,面经,JVM)