JVM相关知识点

  • JVM
    • 自动内存管理子系统
      • 运行时数据区:
        • 方法区线程共享的
          • 主要存储对象的元数据信息(类名,字段信息,方法信息,父类信息等)和运行时常量池(字符串(JDK1.7以前是存放的对象,后面改成了存放引用,对象放到堆内存中了)/数值/布尔字面量,符号引用(类全限定名,字段的描述信息,方法的描述信息))
          • 实现方式
            • JDK1.8以前实现方式是永久代(主要为了适配堆的分代结构方便垃圾回收器一起管理)
            • JDK1.8后采用元空间实现(使用本地内存来存储,与堆内存分离,内存大小可以动态扩展,大大减少了内存溢出的风险
        • 线程共享的
          • 对象的创建过程

          • 当JVM遇到一条new语句时,会先去找常量池找到此类的符号引用,并检查它是否已加载,否则执行类加载过程,通过后,JVM会为新对象在堆中分配固定内存(其大小在类加载完成后便以确定),同时初始化零值(对象头除外),接着设置对象头(锁状态信息,GC分代年龄,类型指针(指向它的类型元数据的指针)等,如果是数组则还要记录它的长度),在JVM看来此时对象已经真正创建完成;接着将会执行方法才按照我们的意愿操作

          • 对象的存储布局

            • 主要由三部分组成:对象头(Mark Word(存放对象自身运行时的数据),类型指针(指向它的类型元数据的指针)),实例数据(真正有效的信息,自己定义的各种类型字段内容),对齐填充(无意义,只是由于HotSpot虚拟机的要求对象的起始地址必须时8字节的整数倍)
          • 垃圾回收器

            • 如何判断垃圾(哪些内存需要回收)

              • “垃圾”指的就是那些不可能被使用的对象
            • 判断是否是垃圾主要有两种算法,分别是

              • 引用计数算法
                • 在对象中添加一个引用计数器,当有引用指向它就+1,只要计数器为0就说明此对象是垃圾,但单纯的此算法无法解决诸如循环引用等问题
              • 可达性分析算法
                • 这个算法的核心就是根节点(GC Roots 如在栈帧引用的对象,类静态属性引用的对象,常量引用的对象(字符串等)等),只要通过根节点找不到这个对象,则表示此对象为垃圾。
                • finalize()方法(已被官方废弃),在它们被回收时,如果有必要执行此方法(此方法只会执行一次),则会放到一个队列另起一个线程执行,然后再次对此队列进行再次扫描。
              • Java中的四种引用类型
                • 强引用(传统的引用,不会被回收)、软引用(SoftReference 当要发生内存溢出时会尝试二次GC回收这些对象)、弱引用(WeakReference下次GC就会直接回收)、虚引用(PhantomReference此引用不起实际的作用,不会对对象的生命周期产生影响,其指向的对象回收后会将虚引用加入到指定的队列,可以得到通知)
            • 垃圾回收算法(追踪式的)

              • 首先这个算法基于分代收集理论(说是理论实则是统计学的经验法则)即:
                • ①大部分对象都是朝生暮死
                • ②熬过越多次GC的对象就越难消亡
              • 所以Java将堆分成了几个不同的区域:新生代和老年代
                • 我们将会对不同的区域进行垃圾回收;但同时对象不是孤立的,会存在跨代引用,但这只是占极少数,因此我们在新生代上建立一个数据结构记忆集)来标识哪些老年代内存需要一起GC
              • 针对这些区域和特点做了相匹配的算法,也就是下面:
              • 标记-清除算法
                • 最基础的版本,分为两个步骤**“标记”“清除”,首先标记要回收的对象,然后统一回收掉所有被标记的对象。它的执行效率不稳定**,因为大量对象都需要回收,标记和清除的动作会导致效率很低,同时产生大量的内存碎片
                • 标记-复制算法
                  • 可以解决这两个问题(适合新生代GC使用)(复制过程可以采用并发来加速提高效率)最早提出的是半区复制,即GC时,将一半空间中存活的对象复制到另一半空间里,不过这会产生大量的空间浪费(新生代存活的对象一般不足十分之一),所以就将新生代分为了Eden区和Survivor区
                  • 新生代:一个较大的Eden区和两个较小的Survivor区(另一个用来存放复制存活的对象),它们默认的大小比例为8:1:1(大大减少了空间的浪费
                • 标记-整理算法
                  • 标记-复制算法对老年代的存活对象较多的场景不适用(复制的操作过多,且存活对象过多),于是在“标记”后,将存活的对象向内侧空间移动,然后直接清理掉边界外的内存适用老年代
                  • 移动操作是一项极为负重的操作,因为移动对象必须全程暂停用户线程才能进行,而这种停顿也被我们戏称为**“Stop The World”**(STW)
            • HotSpot内部的算法实现细节

              • GCRoot的相关细节
                • GC Roots枚举
                  • 要实现找到垃圾的可达性分析,最终要的就是GC Roots的寻找,由于在此时会STW(要做到准确收集),但HotSpot虚拟机会使用一个Map(OopMap),会将对象的偏移量计算后写入,来加速扫描过程。
                • 安全点
                  • 在每个方法都生成OopMaps(记录信息)是不现实的,所以设置了安全点的概念,只在这生成,同时这里会执行全局操作(GC、类卸载等),要确保状态的可预知性,同时不能安全点设置不能太多也不能太少(会设置在方法调用,循环,异常等指令跳转的地方),线程会通过轮询一个标志位判断是否需要中断(或是虚拟机强制中断线程)
                • 记忆集
                  • 上面讲到为了解决跨代之间的对象引用而建立的数据结构(记录的粒度可以是字长,对象,或者“卡”(一个内存区域,卡表独有))
                  • 卡表作为记忆集的一种实现方式(一个字节数字),它以“卡页”作为精度,一个卡页会包含多个对象,如果其中有一个对象的字段存在跨代引用,则表明这个元素变脏,其通过写屏障技术维护卡表状态(会为所有赋值操作生成相应指令,相当于虚拟机层面的AOP)
              • 并发判断是否是垃圾(并发的可达性分析)
                • 在此需要引入三色标记来帮助理解为什么并发判断垃圾会出现问题
                • 三色标记白色:未标记,黑色:标记完成的存活对象,灰色:其指向的对象没有标记完
                • 并发标记会出现如下两个问题(漏标和多标
                  • 当插入一条黑色到白色的引用同时删除所有灰色到此白色的引用,那么白色引用将无法被找到
                    • 这会直接影响到程序的运行,是不可容忍的
                    • 为了解决这个问题提出了增量更新原始快照两种解决方案
                    • 前者是让黑色对象插入白色引用时变为灰色对象。
                    • 后者是在灰色对象删除白色引用时将白色对象变为灰色,重新扫描。
                  • 当删除一条黑色到灰色的引用,虚拟机会无法感知,可能出现内存泄漏(浮动垃圾)
                    • 只需要等到下次GC清理就行
            • 不同的GC的特点和使用场景

              • 经典的GC的组合:
                • Serial + Serial Old(简单高效,小型机器使用)
                  • 都是单线程垃圾回收器,全程都需要STWSerial采用标记-复制算法(Eden和Survivor)负责新生代进行回收,Serial Old则采用标记-整理算法负责老年代GC
                • ParNew(JDK 7前首选的新生代收集器) + CMS
                  • ParNew 实际上就是Serial多线程版本,使用多条线程并行处理垃圾,其它与Serial并无差异
                  • CMS负责老年代GC,它第一次实现了并发的垃圾收集,从名字就可以看出(Concurrent Mark Sweep)它是基于标记-清除算法实现,它的执行包含四个步骤
                  • GC过程:①初始标记。②并发标记。③重新标记。④并发清除①和③仍然需要STW
                  • ①只是标记GC Roots能直接关联的对象。②是遍历整个对象图标记垃圾的过程。
                    ③则是增量更新的过程。④是清除垃圾的过程(不需要移动对象,所以可以并发)
                  • CMS存在需多问题,最严重的是它不能处理浮动垃圾和空间碎片,GC失败后会调用Serial Old
                • PS(Parallel Scavenge) + PO(Parallel Old)
                  • PS是基于标记-复制算法并发收集的新生代GC,它的主要特点是关注点不同,它关注吞吐量(处理器用于业务代码的时间占比)的大小,适合需要后台运行不需要太多交互的应用,它的标记阶段是并行的,在清理垃圾时是STW的,因为需要移动对象
                  • PO是PS老年代的版本是基于标记-整理算法实现的
                • G1(Garbage First) (JDK 9开始是服务端模式下的推荐方案了,之前是PS + PO)
                  • G1是一个面向全堆的收集器,它从整体上看是标记-整理算法实现,从局部(两个Region)上看又是基于标记-复制算法实现。它第一次提出了基于Region内存布局形式。但是G1没有抛弃分代的理念,每个Region都可以根据需要扮演对应的区域
                  • G1的垃圾回收可以针对任意区域(一个回收集,称为CSet)进行回收,而不是只局限与整个年龄代,根据回收的收益来进行回收,这就是它的Mixed GC模式,同时可以做到可控的GC停顿时间(用户可以通过参数指定)(衰减平均值的概念)
                  • 维护跨Region的引用对象会变得非常复杂,需要耗费过多的内存来维护,成为额外负担
                  • GC过程:①初始标记。②并发标记。③最终标记。④筛选回收①和③和④仍然需要STW
                  • 标记GC Roots②如果用户新创建对象则会在Region中设置两个指针标记,加入其中会默认其是存活的③是原始快照过程。④负责统计Region的回收价值制定回收计划,最后把存活的对象复制到空的Region中再清理掉旧的Region(此时需要STW再多线程执行移动对象)
              • 高性能GC:
                • Shenandoah(非官方的GC)
                  • G1的继承者,不同的是它支持并发的整理算法,其次它抛弃了分代设计,此外将记忆集的实现方式改成了连接矩阵全局数据结构。
                  • **GC过程:**①初始标记。②并发标记。③最终标记。④并发清理。⑤并发回收。
                    ⑥初始引用更新。⑦并发引用更新。⑧最终引用更新。⑨并发清理①③⑥⑧需要STW
                  • ①②③与G1基本一样清理没有存活对象的Region先将存活对象复制到一个没有用过的Region中。确定一个执行点,确保⑤已经执行完成,此时会STW。将堆中的所有指向旧对象的指针指向新地址修改GC Root的引用,此时会STW。再次调用④过程。、
                  • 并发移动时,采用转发指针来防止访问旧对象(在对象中加入一个新的引用字段,默认指向自己,而在移动后,指向新地址)
                • ZGC(它的设计注定它能承受的对象分配速率不会太高)
                  • 是一款基于Region内存布局的,不采用分代设计的GC。其使用读屏障,染色指针和内存多映射等技术实现并发标记-整理算法
                  • 与G1和Shenandoah不同,它的Region具有动态性,分小、中、大三类,对应不同大小的对象
                  • 染色指针是它的标志(Linux中通过多重映射的方式来支持),即将一部分信息存在指针上(4位,三色标记状态,是否移动等),所以这会导致ZGC可以管理的内存大小不能超过4TB(2^42),同时它也不能支持指针压缩(将64位的指针压缩成32位,减少内存消耗),它可以使Region被移走后直接释放
                  • GC过程:①并发标记。②并发预备重分配。③并发重分配。④并发重映射。
                  • 与G1和Shenandoah一样的标记过程扫描所有的Region(省去了记忆集的开销),确定回收的Region的集合③复制存活对象到新的Region中,并为老Region维护一个转发表,当有引用通过转发表进行访问时,会重新更新它的引用,减少后续的开销(指针自愈修正整个堆的旧引用,与Shenandoah的⑦基本一样,不过ZGC会在并发标记时一起执行(自愈)
        • 线程私有的
    • 执行子系统:
      • Class文件的结构
        • 开头就是魔术与Class文件的版本号,接着就是Class文件的常量池
      • 类加载机制
        • 执行时机

          • ①使用new关键词实例化对象时
            ②读取或设置类的静态字段时(final修饰,已在编译器放入常量池的静态字段除外)
            ③调用一个类的静态方法时
        • 类的生命周期

          • 加载、验证、准备、解析(三个步骤被称为连接)
            初始化、使用、卸载
        • 类加载过程

          • 加载阶段:
            ①通过类的全限定名获取字节流(如果数组的组件类型是引用类型,将会递归执行此过程)
            ②将静态结构转化为方法区的数据结构
            ③在堆中生成一个Class对象(JDK1.8后移动到堆中,逻辑上属于方法区),作为访问入口
            • 类加载器
              • 加载阶段是可控性最强的阶段,可以重写类加载器的findClass或者loadClass方法来修改获取字节流的方式,实现自定义获取程序代码。
              • 双亲委派模型(指各个类加载器之间的层次关系)
                • 除了启动类加载器,其余类加载器都应有自己的父类加载器
              • 双亲委派机制
                • 一个类加载器收到类加载请求,它会先委派父类加载器加载,每个加载器都如此,当父类加载器无法加载时,子类加载器才会尝试加载。
                • 能够避免类的重复加载,同时确保核心类都由虚拟机自行加载,增加了安全性
              • 破坏双亲委派机制
                • 重写loadClass方法
          • 连接(验证,准备,解析)阶段
            • 验证阶段:确保Class文件的内容符合规范约束(如果确认了,则此步骤可以通过参数关闭加速启动时间)
              准备阶段:正式为类中定义的变量(即静态变量)分配内存并设置零值
              解析阶段:是将Class文件的常量池内的符号引用替换为直接引用。(此处可能会触发别的类加载)
          • 初始化阶段(类加载过程的最后阶段
            • 分配对象内存空间
              初始化对象
              将指针指向刚才分配的内存空间
      • 执行代码的过程
        • 执行引擎(虚拟机字节码执行引擎)
          -虚拟机是一个相对物理机的概念,物理机的执行引擎直接建立在硬件和操作系统层面上的,而虚拟机执行引擎则是由软件自行实现,因此可以不受制约实现其它指令集格式。
          -虚拟机执行引擎在执行字节码时通常会有解释执行编译执行两种方式(或同时存在)。输入字节码二进制流,处理过程就是对字节码的解释执行,输出执行结果
          • 栈帧(Java虚拟机以方法作为最基本的执行单位)
            • 其中包括了局部变量表操作数栈方法返回地址等信息(其大小一开始就已经确定了,固定的)
            • 局部变量表:是变量的存储空间(存放方法参数和方法内部定义的局部变量,非静态方法第0位为this变量)
              操作数栈:是一个先进后出的栈(栈的最大深度也是一开始就确定了)
          • 方法调用(只是确定调用版本并未执行方法)
            • 静态解析:针对的是编译期就可知调用版本的方法,主要是静态方法(直接与类相关)和私有方法(外部不可访问)两类
            • 分派(多态的具体体现):
              • 静态分派:依赖静态类型来决定执行版本的分派(最典型的就是方法重载
                对于Human man = new Man();Human 为静态类型,Man为实际类型
                其在编译期就会由编译器通过静态类型参数确定执行版本,被称为多分派类型
              • 动态分派:根据对象的实际类型来执行版本的分派(方法重写和多态
                运行期才能知道方法的调用目标实际类型
                只会根据实际类型进行分派(此时参数类型已经确认),被称为单分派类型
              • 动态分派优化:由于此操作极其频繁,会在方法区中创建一个虚方法表用来代替元数据的查找。
            • **Reflection和MethodHandle(java.lang.invoke包)**的区别:反射(Reflection)是重量级操作,是对象的全面映射,而后者则只是包含该方法的相关信息,较之是轻量级操作
    • 编译子系统:
      • 前端编译优化
        • Javac
        • 语法糖
          • 泛型:Java的泛型实现方式为“类型擦除式泛型”,即Java中的泛型只在源码中出现,编译后的字节码中都会被替换为裸类型,并在相应地方插入强制转换代码。(目的就是为了兼容旧版本)
          • 自动装箱、拆箱:大量的诸如Integer.valueOf的方法,在“==”运算或者equals方法中时不会起作用
          • for-each的迭代器;条件不成立的分支自动去除;枚举类、Lambda表达式等等。
      • 后端编译优化
        • 解释器:它可以直接运行程序,不需要编译;它还可以节约内存;且是编译优化失败后的底牌。
        • 即时编译器(C1,C2编译器)
          • 针对的是热点代码(即多次执行的循环体或者被多次调用的方法
            当对循环体优化时,会发生栈上替换,在方法运行时就将其替换
          • 热点探测(有两种方式)
            • 基于采样的热点探测:会周期性的查看各个线程的栈顶,如果发现某个方法出现频率过高,就是热点;其缺点就是无法准确确认一个方法的热度,容易受线程阻塞或者外界执行的因素的影响
            • 基于计数器的热点探测:HotSpot为每个方法准备了两个计数器,方法调用计数器回边计数器;当有其中一个超过阈值,则为热点。
              方法调用计数器:见名知意,统计方法调用次数,在默认情况下,它统计的不是绝对次数,而是相对的频率,当一定时间过后,如果还未到阈值,则会减少一半(被称为热度的衰减
              回边计数器:它的作用是统计循环中代码执行的次数,其没有热度衰减,统计的是绝对次数
          • 分层编译的概念:
            • 主要由于即使编译器编译需要占用资源,所以为了平衡其影响提出了分层编译的功能
              (客户端编译器,服务端编译器)
        • 提前编译器
        • 优化技术
          • 方法内联:最基础也是最重要的优化,是其它优化的基础。通过将方法中的代码“复制”到调用的位置,可以去除调用时的成本,同时为其它优化奠定基础
            虚方法的内联:非虚方法没有什么问题,但虚方法则很难确定调用者的类型,如果查询到只有一个版本,则将此内联称为守护内联Java动态连接的,所以有可能被打破);如果是多个版本,则会去调用方法,但会使用一个缓存来缩减调用开销。
          • 逃逸分析:它也是为其它优化提供基础的分析技术;通过分析对象的动态作用域,分为方法逃逸,线程逃逸,以及从不逃逸。
            据此它分为三种优化方式
            • 标量替换:将其成员变量的访问恢复原始对象的访问,它不允许逃逸到方法外面
            • 栈上分配分配到栈上,可以省去GC的开销,随着栈帧的出栈而回收,它不允许逃逸到线程外面
            • 同步消除:对不能逃逸到线程外面的代码取消同步措施
    • 高效并发:阿姆达尔(Amdahl)定理代替摩尔定律成为计算机性能发展的源动力)
      • Java内存模型:
        • 三大特性:
          原子性:指此操作不可分割和中断的,对于基本数据类型的访问,读写都具备原子性
          可见性:指一个线程修改了共享变量时,其它线程能够立即得知这个修改。
          有序性:在线程内观察,所有操作都是有序的,而线程外看起来则是无序的,先行发生原则(happens-before原则)
        • 乱序执行
          处理器会对输入代码进行乱序执行优化,是机器级别的优化
      • 线程的运行状态
        ①新建(创建但尚未运行)
        ②运行(运行中的线程)
        ③无限期等待(未设置等待时间的线程,等待唤醒动作)
        ④限期等待(设置等待时间的线程,等待唤醒动作或者时间)
        ⑤阻塞(与等待的区别是,它在等待获取一个排他锁的释放)
        ⑥结束(终止后的线程状态)
      • 互斥同步:(互斥是方法,同步是目的)
        • synchronized
          这是一种块结构的同步语法,会在同步代码块前后生成monitorenter和monitorexit这两个字节码指令,其需要明确加锁对象(Java中如果没指定了加锁对象,则根据锁定方法来判断是对象实例实例方法)还是Class对象类方法))
          • monitorenter会去获取对象的锁,如果获取成功,锁的计数器加一,monitorexit也是如此,所以其是可重入锁
        • Lock接口:(JUC包下的)非块结构来实现互斥同步
          • ReentrantLock重入锁,和synchronized一样也是可重入的,但其多了三大功能:等待可中断(等待的线程可以选择放弃等待),公平锁(可以按照等待顺序依次获得锁,默认不公平的,开启后会使得性能下降),锁绑定多个条件(锁对象可以绑定多个Condition对象)。
        • 互斥同步主要问题是进行线程阻塞和唤醒所带来的开销,所以诞生了非阻塞的同步CAS(比较并交换)就是其中之一。CAS存在一个ABA问题(可以通过版本号来解决,但一般此问题对程序不会有大影响)。
      • 锁优化:
        • 自旋锁
          互斥同步最大的性能开销就是阻塞,同时共享变量的锁定状态一般不会持续很久,所以不需要每次都直接等待挂起,而是让其进行一个忙循环自旋)。如果多次自旋成功,可能会允许自旋的次数更多,反之则可能会直接略过自旋避免浪费处理器资源自适应自旋)。
        • 偏向锁
          目的为了消除无竞争下的同步操作,它会偏向于第一个获得它的线程,如果没有竞争,则此线程无需执行同步操作(在竞争激烈的程序中可以关闭此功能提升性能)。(在对象头中设置标志位)一旦有别的线程尝试获取这个对象的锁,偏向锁就会结束,转为轻量级锁
        • 轻量级锁
          对象没有被锁定时,虚拟机会为此对象的Mark Word的拷贝,然后通过CAS来将Mark Word更新为指向栈帧的指针,同时将锁标志位设置为轻量级锁定;如果失败则意味这有其它线程在竞争,此时需要膨胀成重量级锁,将指针更新为互斥量指针释放锁也是同理,如果CAS更新Mark Word失败,则需要唤醒其它被挂起的线程
        • 锁消除:主要通过逃逸分析的支持,如果无法被其它线程访问到,则可以将它们的同步语句去除
        • 锁粗化:有些情况下连续的一串操作中会不停的加锁解锁(一些方法调用时),此时虚拟机会将这一些零散的加锁操作变成一个范围的加锁同步