Java JVM 学习笔记01

JVM 常见问题总结

Java 引用的四种状态

  • 强应用:
    • 用的最广。我们平时写代码时,new一个Object存放在堆内存,然后用一个引用指向它,他就是强引用。
    • 如果一个对象具有强引用,那垃圾回收其绝不会回收它。当内存空间不足时,Java JVM宁愿抛出OutOfMemoryError错误,使程序终止,也不会随意回收对象来解决内存不足。
  • 软引用:
    • 如果一个对象只具有软引用,则内存空间足够时,垃圾回收器就不会回收它;***如果内存空间不足了,就会回收这些对象的内存。***(备注:如果内存不足,随时有可能被回收。)
    • 只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
  • 弱引用:
    • 弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期
    • 每次执行GC的时候,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
    • 不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。
  • 虚引用:
    • 虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
    • 虚引用主要用来跟踪对象被垃圾回收器回收的活动。

Java 内存的划分

Java JVM 学习笔记01

  • 程序计数器

    • 线程私有
    • 保证线程切换后能恢复到原来的执行位置
    • 为了线程切换后能够恢复到正确的执行位置,每条线程都有一个独立的程序计数器,这块儿属于“线程私有”的内存。
  • Java虚拟机栈

    • 线程私有
    • (栈内存)为虚拟机执行java方法服务:方法被调用时创建栈帧–>局部变量表->局部变量、对象引用
    • 每个方法被调用直到执行完成的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。
    • 在Java虚拟机规范中,对这个区域规定了两种异常情况:
      • 如果线程请求的栈深度太深,超出了虚拟机所允许的深度,就会出现StackOverFlowError。
      • 虚拟机栈可以动态扩展,如果扩展到无法申请足够的内存空间,会出现OOM
  • 本地方法栈

    • 线程私有
    • 为虚拟机执使用到的Native方法服务
    • 本地方法栈与java虚拟机栈作用非常类似,其区别是:java虚拟机栈是为虚拟机执行java方法服务的,而本地方法栈则为虚拟机执使用到的Native方法服务。
    • Java虚拟机没有对本地方法栈的使用和数据结构做强制规定,Sun HotSpot虚拟机就把java虚拟机栈和本地方法栈合二为一。
    • 本地方法栈也会抛出StackOverFlowError和OutOfMemoryError。
  • 堆内存

    • 线程共享
    • 存放所有new出来的东西
    • 堆是GC管理的主要区域,从垃圾回收的角度看,由于现在的垃圾收集器都是采用的分代收集算法,因此java堆还可以初步细分为新生代和老年代。
    • Java虚拟机规定,堆可以处于物理上不连续的内存空间中,只要逻辑上连续的即可。在实现上既可以是固定的,也可以是可动态扩展的。如果在堆内存没有完成实例分配,并且堆大小也无法扩展,就会抛出OutOfMemoryError异常。
  • 方法区

    • 线程共享
    • 存储被虚拟机加载的类信息、常量、静态常量、静态方法等。
    • 方法区中最终要的部分是运行时常量池。
  • 字符串常量池

    • 运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时就会抛出OutOfMemoryError异常。
  • GC对于它们的回收:

    • 内存区域中的程序计数器、虚拟机栈、本地方法栈这3个区域随着线程而生,线程而灭。
    • 栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈的操作,每个栈帧中分配多少内存基本是在类结构确定下来时就已知的。
  • GC 做了哪些工作

    • Java堆中主要是有新生代和年老代,他们采用不同的回收方式,例如新生代采用了标记复制的算法(因为其生存时间比较短),新生代进行gc的时候,会把eden区存活的对象放到另外一个survival区域里面,然后把eden区和另外一个survival区清除。而年老代采用了标记清除的算法,首先标记出存活的对象,然后移到另一端,这样也能减少内存碎片化。

Java 对象在内存中的状态

  • 可达的/可触及的:
    • Java对象被创建后,如果被一个或多个变量引用,那就是可达的。即从根节点可以触及到这个对象。
    • 其实就是从根节点扫描,只要这个对象在引用链中,那就是可触及的。
  • 可恢复的:
    • Java对象不再被任何变量引用就进入了可恢复状态。
    • 在回收该对象之前,该对象的finalize()方法进行资源清理。如果在finalize()方法中重新让变量引用该对象,则该对象再次变为可达状态,否则该对象进入不可达状态
  • 不可达的:
    • Java对象不被任何变量引用,且系统在调用对象的finalize()方法后依然没有使该对象变成可达状态(该对象依然没有被变量引用),那么该对象将变成不可达状态。
    • 当Java对象处于不可达状态时,系统才会真正回收该对象所占有的资源。

对象死亡的两种常用算法:

  • 引用计数算法
    • 给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
    • 但是,主流的java虚拟机并没有选用引用计数算法来管理内存,其中最主要的原因是:它很难解决对象之间相互循环引用的问题。
    • 优点:
      • 算法的实现简单,判定效率也高,大部分情况下是一个不错的算法。
    • 缺点:
      • 引用和去引用伴随加法和减法,影响性能
      • 致命的缺陷:对于循环引用的对象无法进行回收
  • 根搜索算法:(jvm采用的算法)
    • 设立若干种根对象,当任何一个根对象(GC Root)到某一个对象均不可达时,则认为这个对象是可以被回收的。
    • 根(GC Roots):
      • 栈(栈帧中的本地变量表)中引用的对象。
      • 方法区中的静态成员。
      • 方法区中的常量引用的对象(全局变量)
      • 本地方法栈中JNI(一般说的Native方法)引用的对象。

垃圾回收算法

  • 标记-清除算法

    • 标记阶段:
      • 先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象;
    • 清除阶段:
      • 清除所有未被标记的对象。
    • 缺点:
      • 标记和清除的过程效率不高(标记和清除都需要从头遍历到尾)
      • 标记清除后会产生大量不连续的碎片。
  • 复制算法

    • 将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,然后清除正在使用的内存块中的所有对象。
    • 优点:
      • 这样使得每次都是对整个半区进行回收,内存分配时也就不用考虑内存碎片等情况
      • 只要移动堆顶指针,按顺序分配内存即可,实现简单,运行效率高
    • 缺点:
      • 浪费空间
  • 标记-整理算法

    • 标记阶段:先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象
    • 整理阶段:将将所有的存活对象压缩到内存的一端;之后,清理边界外所有的空间
    • 优点:
      • 不会产生内存碎片
    • 缺点:
      • 在标记的基础之上还需要进行对象的移动,成本相对较高,效率也不高。
  • 算法比较:

    • 效率:复制算法 > 标记/整理算法 > 标记/清除算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。
    • 内存整齐度:复制算法=标记/整理算法>标记/清除算法。
    • 内存利用率:标记/整理算法=标记/清除算法>复制算法。
  • 分代收集算法

    • 当前商业虚拟机的GC都是采用的“分代收集算法”,这并不是什么新的思想,只是根据对象的存活周期的不同将内存划分为几块儿。一般是把Java堆分为新生代和老年代:短命对象归为新生代,长命对象归为老年代。
    • 存活率低:少量对象存活,适合复制算法:
      • 在新生代中,每次GC时都发现有大批对象死去,只有少量存活(新生代中98%的对象都是“朝生夕死”),那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成GC。
    • 存活率高:大量对象存活,适合用标记-清理/标记-整理:
      • 在老年代中,因为对象存活率高、没有额外空间对他进行分配担保,就必须使用“标记-清理”/“标记-整理”算法进行GC。

垃圾收集器

  • 如果说收集算法时内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。虽然我们在对各种收集器进行比较,但并非为了挑出一个最好的收集器。因为直到现在位置还没有最好的收集器出现,更加没有万能的收集器,所以我们选择的只是对具体应用最合适的收集器。
  • Serial 收集器:(串行收集器)
    • 这个收集器是一个单线程的收集器,但它的单线程的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作。
    • 更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程(Stop-The-World:将用户正常工作的线程全部暂停掉),直到它收集结束。
    • 新生代:采用复制算法
    • 老年代:采用标记-整理算法
    • 当它进行GC工作的时候,虽然会造成Stop-The-World,但它存在有存在的原因:正是因为它的简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,没有线程交互的开销,专心做GC,自然可以获得最高的单线程手机效率。
    • 所以Serial收集器对于运行在client模式下是一个很好的选择(它依然是虚拟机运行在client模式下的默认新生代收集器)。
  • ParNew收集器:(使用多条线程进行GC)
    • ParNew收集器是Serial收集器的多线程版本。
    • 它是运行在server模式下的首选新生代收集器,除了Serial收集器外,目前只有它能与CMS收集器配合工作。
    • 新生代:采用复制算法
    • 老年代:采用标记-整理算法
  • ParNew Scanvenge 收集器:
    • 类似ParNew,但更加关注吞吐量。目标是:达到一个可控制吞吐量的收集器。
    • 停顿时间和吞吐量不可能同时调优。
  • G1收集器
    • 是当今收集器发展的最前言成果之一,知道jdk1.7,sun公司才认为它达到了足够成熟的商用程度。
    • 优点:
      • 它最大的优点是结合了空间整合,不会产生大量的碎片,也降低了进行gc的频率。
  • CMS收集器(老年代收集器)
    • CMS收集器更加关注停顿,它在做GC的时候是和用户线程一起工作的(并发执行),如果使用标记整理算法的话,那么在清理的时候就会去移动可用对象的内存空间,那么应用程序的线程就很有可能找不到应用对象在哪里。
    • CMS收集器(Concurrent Mark Sweep:并发标记清除)是一种以获取最短回收停顿时间为目标的收集器。适合应用在互联网站或者B/S系统的服务器上,这类应用尤其重视服务器的响应速度,希望系统停顿时间最短。
    • CMS收集器运行过程:
        1. 初始标记:根可以直接关联到的对象,速度快
        1. 并发标记(和用户线程一起): 主要标记过程,标记全部对象。
        1. 重新标记:由于并发标记时,用户线程依然运行,因此在正式清理前,再做修正
        1. 并发清除(和用户线程一起):基于标记结果,直接清除对象
    • 优点:
      • 并发收集,低停顿。
    • 缺点:
      • 导致用户的执行速度降低。
      • 无法处理浮动垃圾。因为它采用的是标记-清除算法。有可能有些垃圾在标记之后,需要等到下一次GC才会被回收。如果CMS运行期间无法满足程序需要,那么就会临时启用Serial Old收集器来重新进行老年代的手机。
      • 由于采用的是标记-清除算法,那么就会产生大量的碎片。往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次full GC

Java对内存划分

  • 根据对象的存活率(年龄),Java对内存划分为3种:新生代、老年代、永久代。
  • GC分为两种:Minor GC 和 Full GC
    • Minor GC:
      • Minor GC是发生在新生代中的垃圾收集动作,采用的是复制算法。
    • Full GC:
      • Full GC是发生在老年代的垃圾收集动作,采用的是标记-清除/整理算法。
  • 新生代:
    • 比如我们在方法中去new一个对象,那这方法调用完毕后,对象就会被回收,这就是一个典型的新生代对象。
  • 老年代:
    • 在新生代中经历了N次垃圾回收后仍然存活的对象就会被放到老年代中。而且大对象直接进入老年代。
  • 永久代:
    • 即方法区

类加载机制

Java JVM 学习笔记01

  • 虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
  • 类的加载过程:
    • 包括加载、链接(含验证、准备、解析)、初始化
  • 加载:
    • 类加载指的是将类的class文件读入内存,并为之创建一个java.lang.Class对象,作为方法区这个类的数据访问的入口。
    • 也就是说,当程序中使用任何类时,系统都会为之建立一个java.lang.Class对象。具体包括以下三个部分:
      • 通过类的全名产生对应类的二进制数据流。
      • 分析并将这些二进制数据流转换为方法区方法区特定的数据结构
      • 创建对应类的java.lang.Class对象,作为方法区的入口(有了对应的Class对象,并不意味着这个类已经完成了加载链接)
    • 通过使用不同的类加载器,可以从不同来源加载类的二进制数据,通常有如下几种来源:
      • 从本地文件系统加载class文件,这是绝大部分程序的加载方式
      • 从jar包中加载class文件,这种方式也很常见,例如jdbc编程时用到的数据库驱动类就是放在jar包中,jvm可以从jar文件中直接加载该class文件
      • 通过网络加载class文件
      • 把一个Java源文件动态编译、并执行加载
  • 链接
    • 链接指的是将Java类的二进制文件合并到jvm的运行状态之中的过程。在链接之前,这个类必须被成功加载。
    • 类的链接包括验证、准备、解析这三步。具体描述如下:
      • 验证:
        • 验证是用来确保Java类的二进制表示在结构上是否完全正确(如文件格式、语法语义等)。如果验证过程出错的话,会抛出java.lang.VertifyError错误。
        • 主要验证以下内容:
          • 文件格式
          • 元数据验证:语义验证
          • 字节码验证
      • 准备:
        • 准备过程则是创建Java类中的静态域(static修饰的内容),并将这些域的值设置为默认值,同时在方法区中分配内存空间。准备过程并不会执行代码。
        •   注意这里是做默认初始化,不是做显式初始化。例如:
          
            public static int value = 12;
          
            上面的代码中,在准备阶段,会给value的值设置为0(默认初始化)。在后面的初始化阶段才会给value的值设置为12(显式初始化)。
          
      • 解析:
        • 解析的过程就是确保这些被引用的类能被正确的找到(将符号引用替换为直接引用)。解析的过程可能会导致其它的Java类被加载。
  • 初始化:
    • 初始化阶段是类加载过程的最后一步。到了初始化阶段,才真正执行类中定义的Java程序代码(或者说是字节码)。
    • 初始化过程:
      • 创建类的实例
      • 访问类或接口的静态变量(特例:如果是用static final修饰的常量,那就不会对类进行显式初始化。static final 修改的变量则会做显式初始化)访问类或接口的静态变量(特例:如果是用static final修饰的常量,那就不会对类进行显式初始化。static final 修改的变量则会做显式初始化)
      • 调用类的静态方法
      • 反射(Class.forName(packagename.className))
      • 初始化类的子类。注:子类初始化问题:满足主动调用,即父类访问子类中的静态变量、方法,子类才会初始化;否则仅父类初始化。
    • java虚拟机启动时被标明为启动类的类
    • final static修饰的静态常量,所以根本就没有调用静态代码块里面的内容,也就是说,没有对这个类进行显式初始化。

面试问题

  • Student s = new Student();在内存中做了哪些事情?
    • 加载Student.class文件进内存
    • 在栈内存为s开辟空间
    • 在堆内存为学生对象开辟空间
    • 对学生对象的成员变量进行默认初始化
    • 对学生对象的成员变量进行显示初始化
    • 通过构造方法对学生对象的成员变量赋值
    • 学生对象初始化完毕,把对象地址赋值给s变量