diff --git a/blogs/Java/口水话/JVM 相关口水话.md b/blogs/Java/口水话/JVM 相关口水话.md index 743e0d0..0598693 100644 --- a/blogs/Java/口水话/JVM 相关口水话.md +++ b/blogs/Java/口水话/JVM 相关口水话.md @@ -29,7 +29,7 @@ JVM 相关口水话 Java 中的运行时数据可以划分为两部分,一部分是线程私有的,包括虚拟机栈、本地方法栈、程序计数器,另一部分是线程共享的,包括方法区和堆。 -程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行指示器。虚拟机栈描述的是 Java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接地址、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈桢在虚拟机中入栈和出栈的过程。本地方法栈和虚拟机栈所发挥的作用是非常相似的,只不过本地方法栈描述的是 Native 方法执行的内存模型。 +程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。虚拟机栈描述的是 Java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接地址、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈桢在虚拟机中入栈和出栈的过程。本地方法栈和虚拟机栈所发挥的作用是非常相似的,只不过本地方法栈描述的是 Native 方法执行的内存模型。 Java 堆是所有线程共享的一块数据区域,主要用来存放对象实例。它也是垃圾收集器管理的主要区域,从内存回收的角度来看,由于现代收集器基本上都采用分代回收,所以 Java 堆还可以细分为新生代和老年代。再细致一点还可以把新生代划分为 Eden 区、From Survivor 区和 To Survivor 区。从内存分配的角度来看,线程共享的 Java 堆中可能划分为多个线程私有的分配缓冲区 TLAB。不过不论如何划分,都与存放内容无关,无论哪个区域,存放的都是对象实例,进一步划分的目的是为了更好的回收内存或者更快的分配内存。方法区是用于存储已被虚拟机加载的类信息、常量、静态变量、即使编译器编译后的代码等数据。JVM 对方法区的限制比较宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾回收。相对而言,垃圾回收在这个区域是比较少出现的。运行时常量池是方法区的一部分,它用来存储编译期生成的各种字面量和符号引用。运行时常量池相比 Class 文件常量池一个重要的特点是具备动态性,也就是在运行期间也可能将新的常量放入池中,比如 String 的 intern 方法。 @@ -45,7 +45,7 @@ Java 内存模型规定了所有的共享变量都是存储在主内存,每个 那么为什么要这么做呢? -其实就要讲到一些硬件知识了,我们知道 CPU 执行的速度是远超于内存访问速度,为了中和这种速度差异,在 CPU 和内存直接会加入多个 CPU 缓存,比如 L1、L2、L3。CPU 在处理数据时会先把内存中的数据读到自己的 CPU 缓存中,然后在缓存中进行操作数据,最后再把数据同步到内存中。这里,就可以把 CPU 的缓存看成是线程的工作内存,而把内存看成是主内存,虽然这个说法并不严谨,但是易于理解。 +其实就要讲到一些硬件知识了,我们知道 CPU 执行的速度是远超于内存访问速度,为了中和这种速度差异,在 CPU 和内存之间会加入多个 CPU 缓存,比如 L1、L2、L3。CPU 在处理数据时会先把内存中的数据读到自己的 CPU 缓存中,然后在缓存中进行操作数据,最后再把数据同步到内存中。这里,就可以把 CPU 的缓存看成是线程的工作内存,而把内存看成是主内存,虽然这个说法并不严谨,但是易于理解。 #### 内存分配回收策略 @@ -71,7 +71,7 @@ Full GC/Major GC 指发生在老年代的 GC,出现 Full GC 经常会伴随着 #### Java 对象的创建、内存布局和访问定位 -先说对象创建,在虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载过了,如果没有就走类加载流程。在类加载检查通过之后,虚拟机就会为新生对象分配内存,对象所需内存在类加载完成之后就确定了。为对象分配内存空间就等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有指针碰撞和空闲列表两种,选择那种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否具有压缩整理功能决定。对象创建在虚拟机是非常频繁的行为,即使是仅仅修改了一个指针指向的位置,在并发情况下也不是线程安全的。解决方案有两种,一种是采用 CAS 配上失败重试,另一种是使用线程私有的分配缓冲区 TLAB。 +先说对象创建,在虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载过了,如果没有就走类加载流程。在类加载检查通过之后,虚拟机就会为新生对象分配内存,对象所需内存在类加载完成之后就确定了。为对象分配内存空间就等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有指针碰撞和空闲列表两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否具有压缩整理功能决定。对象创建在虚拟机是非常频繁的行为,即使是仅仅修改了一个指针指向的位置,在并发情况下也不是线程安全的。解决方案有两种,一种是采用 CAS 配上失败重试,另一种是使用线程私有的分配缓冲区 TLAB。 接着是对象的内存布局,在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头、实例数据和对其填充。可以使用 OpenJDK 开源的 JOL 工具查看对象的内存布局,直接 new Object 所占用的大小为 16 字节,即 12 个字节的对象头 + 4 个字节的对其填充。JOL 对分析集合源码扩容、HashMap 的 hash 冲突等非常有用。 @@ -83,23 +83,23 @@ Full GC/Major GC 指发生在老年代的 GC,出现 Full GC 经常会伴随着 ##### 引用计数及可达性分析 -引用计数法就是给对象添加一个引用计数器,每当有一个地方引用时就加一,引用失效时就减一。引用计数实现简单,判断效率也很高,但是 JVM 并没有采用引用计数来管理内存,其中最主要的原因使它很难解决对象之间的相互循环引用问题。可达性分析的思路是通过一系列称为 GC Roots 的对象作为起始点,从这些起始点出发向下搜索,当有一个对象到 GC Roots 没有任何引用链时,即不可达,则说明此对象是不可用的。在 Java 中,可作为 GC Roots 的对象有虚拟机栈和本地方法栈中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象等。 +引用计数法就是给对象添加一个引用计数器,每当有一个地方引用时就加一,引用失效时就减一。引用计数实现简单,判断效率也很高,但是 JVM 并没有采用引用计数来管理内存,其中最主要的原因是它很难解决对象之间的相互循环引用问题。可达性分析的思路是通过一系列称为 GC Roots 的对象作为起始点,从这些起始点出发向下搜索,当有一个对象到 GC Roots 没有任何引用链时,即不可达,则说明此对象是不可用的。在 Java 中,可作为 GC Roots 的对象有虚拟机栈和本地方法栈中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象等。 -但这也并不是说引用计数一无是处,在 Android 的 Framework Native 层用的智能指针。智能指针就是一种能够自动维护对象引用计数的技术,它是一个对象而不是一个指针,但是它引用了一个实际使用的对象。简单来说,就是在智能指针构造时,增加它所引用的对象的引用计数;而在智能指针析构时,就减少它所引用对象的引用对象。但是它是怎样解决相互引用问题的呢?其实是通过强弱引用来实现,也就是将对象的引用计数分为强引用计数和弱引用计数两种,其中,对象的生命周期只受强引用计数控制。比如在解决对象 A 和 B 相互引用时,把 A 看成父 B 看成子,对象 A 通过强引用计数来引用 B,B 通过若引用计数来引用 A。在 A 不再使用时,由于 B 是通过弱引用来引用它的,因此 A 的生命周期是不受 B 影响的,所以 A 可以安全的释放,在释放 A 时,同时也会释放它对 B 的强引用计数,这是 B 也可以被安全的回收了。在 Android 中,是使用 sp 来表示强引用,wp 表示弱引用。 +但这也并不是说引用计数一无是处,在 Android 的 Framework Native 层用的智能指针。智能指针就是一种能够自动维护对象引用计数的技术,它是一个对象而不是一个指针,但是它引用了一个实际使用的对象。简单来说,就是在智能指针构造时,增加它所引用的对象的引用计数;而在智能指针析构时,就减少它所引用对象的引用对象。但是它是怎样解决相互引用问题的呢?其实是通过强弱引用来实现,也就是将对象的引用计数分为强引用计数和弱引用计数两种,其中,对象的生命周期只受强引用计数控制。比如在解决对象 A 和 B 相互引用时,把 A 看成父 B 看成子,对象 A 通过强引用计数来引用 B,B 通过弱引用计数来引用 A。在 A 不再使用时,由于 B 是通过弱引用来引用它的,因此 A 的生命周期是不受 B 影响的,所以 A 可以安全的释放,在释放 A 时,同时也会释放它对 B 的强引用计数,这时 B 也可以被安全的回收了。在 Android 中,是使用 sp 来表示强引用,wp 表示弱引用。 -Java 中的引用可以分为四类,强引用、软引用、弱引用和虚引用。强引用在程序中普遍存在,类似 new 的这种操作,只要有强引用存在,即使 OOM JVM 也不会回收该对象。软引用是在内存不够用时,才回去回收,JDK 提供了 SoftReference 类来实现软引用。弱引用是在 GC 时不管内存够不够用会去回收的,可以使用 WeakReference 类来实现弱引用。虚引用对对象的生命周期没有影响,只是为了能在对象回收时收到一个系统通知,可以使用 PhantomReference 类来实现虚引用。 +Java 中的引用可以分为四类,强引用、软引用、弱引用和虚引用。强引用在程序中普遍存在,类似 new 的这种操作,只要有强引用存在,即使 OOM JVM 也不会回收该对象。软引用是在内存不够用时,才会去回收,JDK 提供了 SoftReference 类来实现软引用。弱引用是在 GC 时不管内存够不够用都会去回收的,可以使用 WeakReference 类来实现弱引用。虚引用对对象的生命周期没有影响,只是为了能在对象回收时收到一个系统通知,可以使用 PhantomReference 类来实现虚引用。 -接下来就是要将垃圾回收算法了。 +接下来就是要讲垃圾回收算法了。 ##### 垃圾回收算法 -垃圾回收算法主要有标记清除、复制算法、标记整理。标记清除是先通过 GC Roots 标记所存活的对象,然后再统一清除未被标记的对象,它的主要问题是会产生内存碎片。老年代使用的 CMS 收集器就是基于标记清除算法。复制算法是把内存空间划分为两块,每次分配对象只在一块内存上进行分配,在这一块内存使用完时,就直接把存活的对象复制到另外一块上,然后把已使用的那块空间一次清理掉,但是这种算法的代价就是内存的使用量缩小了一半。现代虚拟机都采用复制算法回收新生代,不过是把内存划分为了一个 Eden 区和两个 Survivor 区,比例是 8:1:1,每次使用 Eden 和其中一块 Survivor 区,也就是只有 10% 的内存会浪费掉。如果 Survivor 空间不够用,需要依赖其他内存比如老年代进行分配担保。复制算法在对象存活率比较高时效率是比较低下的,所以老年代一般不使用复制算法。标记整理算法即是在标记清除之后,把所有存活的对象都向一段移动,然后清理掉边界以外的内存区域。 +垃圾回收算法主要有标记清除、复制算法、标记整理。标记清除是先通过 GC Roots 标记所存活的对象,然后再统一清除未被标记的对象,它的主要问题是会产生内存碎片。老年代使用的 CMS 收集器就是基于标记清除算法。复制算法是把内存空间划分为两块,每次分配对象只在一块内存上进行分配,在这一块内存使用完时,就直接把存活的对象复制到另外一块上,然后把已使用的那块空间一次清理掉,但是这种算法的代价就是内存的使用量缩小了一半。现代虚拟机都采用复制算法回收新生代,不过是把内存划分为了一个 Eden 区和两个 Survivor 区,比例是 8:1:1,每次使用 Eden 和其中一块 Survivor 区,也就是只有 10% 的内存会浪费掉。如果 Survivor 空间不够用,需要依赖其他内存比如老年代进行分配担保。复制算法在对象存活率比较高时效率是比较低下的,所以老年代一般不使用复制算法。标记整理算法即是在标记清除之后,把所有存活的对象都向一端移动,然后清理掉边界以外的内存区域。 -最后就是将垃圾回收算法的具体应用了,也就是垃圾收集器。 +最后就是讲垃圾回收算法的具体应用了,也就是垃圾收集器。 ##### G1 及 ZGC -Garbage First(G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。它和 CMS 同样是一款主要面向服务端应用的垃圾收集器,不过在 JDK9 之后,CMS 就被标记为废弃了,G1 作为默认的垃圾收集器,在 JDK 14 已经正式移除 CMS 了。在 G1 收集器出现之前的所有其他收集器,包括 CMS 在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),在要么就是整个 Java 堆(Full GC)。而 G1 是基于 Region 堆内存布局,虽然 G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间或者老年代。收集器根据 Region 的不同角色采用不同的策略去处理。G1 会根据用户设定允许的收集停顿时间去优先处理回收价值收益最大的那些 Region 区,也就是垃圾最大的 Region 区,这就是 Garbage First 名字的由来。 +Garbage First(G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。它和 CMS 同样是一款主要面向服务端应用的垃圾收集器,不过在 JDK9 之后,CMS 就被标记为废弃了,G1 作为默认的垃圾收集器,在 JDK 14 已经正式移除 CMS 了。在 G1 收集器出现之前的所有其他收集器,包括 CMS 在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),在要么就是整个 Java 堆(Full GC)。而 G1 是基于 Region 堆内存布局,虽然 G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间或者老年代。收集器根据 Region 的不同角色采用不同的策略去处理。G1 会根据用户设定允许的收集停顿时间去优先处理回收价值收益最大的那些 Region 区,也就是垃圾最多的 Region 区,这就是 Garbage First 名字的由来。 G1 收集器的运作过程大致可划分为以下四个步骤: @@ -137,13 +137,13 @@ ZGC 在 JDK11 被引入,作为新一代的垃圾回收器,在设计之初就 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用以一组符号来描述所引用的目标,直接引用可以是直接指向目标的指针。 -初始化阶段是执行类构造器 \() 方法的过程。\() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。虚拟机会保证一个类的 \() 方法再多线程环境中被正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 \() 方法,其他线程都需要阻塞等待,这也是静态内部类能实现单例的主要原因之一。 +初始化阶段是执行类构造器 \() 方法的过程。\() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。虚拟机会保证一个类的 \() 方法在多线程环境中被正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 \() 方法,其他线程都需要阻塞等待,这也是静态内部类能实现单例的主要原因之一。 #### 双亲委派模型 双亲委派模型的工作过程是:如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的类加载器都是如此,因此所有的类加载请求最终都应该传送给顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。 -使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。比如 Object 类,无论哪个类加载器去加载,应用程序各种加载器环境中都是同一个类,同时也避免了重复加载。而且,双亲委派模型也保重了 Java 程序的稳定运作。比如在应用程序中你是不能直接使用 UnSafe 这一不安全操作的类的。 +使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。比如 Object 类,无论哪个类加载器去加载,应用程序各种加载器环境中都是同一个类,同时也避免了重复加载。而且,双亲委派模型也保证了 Java 程序的稳定运作。比如在应用程序中你是不能直接使用 UnSafe 这一不安全操作的类的。 双亲委派模型的实现相对简单,代码都集中在 ClassLoader 的 loadClass 方法中先检查是否已经被加载过了,如果没加载则先调用父加载器的 loadClass 方法,若父加载器为空则使用默认的启动类加载器作为父加载器。如果父加载器加载失败,抛出 ClassNotFoundException 异常,然后调用自己的 findClass 方法进行加载。 @@ -157,7 +157,7 @@ ZGC 在 JDK11 被引入,作为新一代的垃圾回收器,在设计之初就 方法内联,它指的是在编译的过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。方法内联不仅可以消除调用本身带来的性能开销,还可以进一步触发更多的优化。因此,它可以算是编译优化里最为重要的一环。以 getter/setter 为例,如果没有方法内联,在调用 getter/setter 时,程序需要保存当前方法的执行位置,创建并压入用于 getter/setter 的栈桢、访问字段、弹出栈桢,最后再恢复当前方法的执行。而当内联了对 getter/setter 的方法调用后,上述操作就只剩下字段访问了。但是即时编译器不会无限制的进行方法内联,它会根据方法的调用次数、方法体大小、Code cache 的空间等去决定是否要进行内联。比如即使是热点代码,如果方法体太大,也不会进行内联,因为会占用更多内存空间。所以平时编码中,尽可能使用小方法体。对于需要动态绑定的虚方法调用来说,即时编译器则需要先对虚方法调用进行去虚化,即转化为一个或多个直接调用,然后才能进行方法内联。说到这,你应该就明白 final/static 的好处了。所以尽量使用 final、private、static 关键字修饰方法,虚方法因为继承,会需要额外的类型检查才能知道实际上调用的是哪个方法。 -逃逸分析是判断一个对象是否被外部方法引用或外部线程访问的分析技术,即时编译器可以根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化。我们先看一下锁消除,如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没有任何意义,因为其他线程并不能获得该锁对象,在这种情况下,即时编译器就可以消除对该不逃逸对象的加锁、解锁操作。比如 synchronized(new Object) 这种操作会被完全优化掉。不过一般不会有人这么写,事实上,逃逸分析的结果更多被用于将新建对象操作转换成栈上分配或者标量替换。我们知道,Java 虚拟机中对象都是在堆上进行分配的,而堆上的内容对任何线程可见,与此同时,JVM 需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。如果逃逸分析能够证明某些新建的对象不逃逸,那么 JVM 完全可以将其分配至栈上,并且在方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间。不过,由于实现起来需要更改大量假设了 “对象只能堆分配” 的代码,因此 HotSpot 虚拟机并没有采用栈上分配,而是使用了标量替换这么一项技术。所谓的标量,就是仅能存储一个值的变量,如果 Java 代码中的局部变量。标量替换这项优化技术,可以看成将原本对对象的字段的访问,替换成一个个的局部变量的访问。 +逃逸分析是判断一个对象是否被外部方法引用或外部线程访问的分析技术,即时编译器可以根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化。我们先看一下锁消除,如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没有任何意义,因为其他线程并不能获得该锁对象,在这种情况下,即时编译器就可以消除对该不逃逸对象的加锁、解锁操作。比如 synchronized(new Object) 这种操作会被完全优化掉。不过一般不会有人这么写,事实上,逃逸分析的结果更多被用于将新建对象操作转换成栈上分配或者标量替换。我们知道,Java 虚拟机中对象都是在堆上进行分配的,而堆上的内容对任何线程可见,与此同时,JVM 需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。如果逃逸分析能够证明某些新建的对象不逃逸,那么 JVM 完全可以将其分配至栈上,并且在方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间。不过,由于实现起来需要更改大量假设了 “对象只能堆分配” 的代码,因此 HotSpot 虚拟机并没有采用栈上分配,而是使用了标量替换这么一项技术。所谓的标量,就是仅能存储一个值的变量,比如 Java 代码中的局部变量。标量替换这项优化技术,可以看成将原本对对象的字段的访问,替换成一个个的局部变量的访问。 #### 虚拟机相关 @@ -195,7 +195,7 @@ JVM 也提供了内联缓存来加快动态绑定,它能够缓存虚方法调 我们可以直接 new Exception 来查看方法调用的栈轨迹,在调用 Method.invoke() 时,是去调用 DelegatingMethodAccessorImpl 的 invoke,它的实际调用的是 NativeMethodAccessorImpl 的 invoke 方法。前者称为委派实现,后者称为本地实现。既然委派实现的具体实现是一个本地实现,那么为啥还需要委派实现这个中间层呢?其实,Java 反射调用机制还设立了另一种动态生成字节码的实现,成为动态实现,直接使用 invoke 指令来调用目标方法。之所以采用委派实现,是在本地实现和动态实现直接做切换。依据注释信息,动态实现比本地实现相比,其运行效率要快上 20 倍。这是因为动态实现无需经过 Java 到 C++ 再到 Java 的切换,但由于生产字节码比较耗时,仅调用一次的话,反而是本地实现要快上三四倍。考虑到很多反射调用仅会执行一次,JVM 设置了阈值 15,在 15 之下使用本地实现,高于 15 时便开始动态生成字节码采用动态实现。这也被称为 Inflation 机制。 -在反手说一下反射的性能开销在哪呢?平时我们会调用 Class.forName、Class.getMethod、以及 Method.invoke 这三个操作。其中,Class.forName 会调用本地方法,Class.getMethod 则会遍历该类的公有方法,如果匹配到,它还将遍历父类的公有方法,可想而知,这两个操作都非常耗时。下面就是 Method.invoke 调用本身的开销了,首先是 invoke 方法的参数是一个可变长参数,也就是构建一个 Object 数组存参数,这也同时带来了基本数据类型的装箱操作,在 invoke 内部会进行运行时权限检查,这也是一个损耗点。普通方法调用可能有一系列优化手段,比如方法内联、逃逸分析,而这又是反射调用所不能做的,性能差距再一次被放大。 +在反手说一下反射的性能开销在哪呢?平时我们会调用 Class.forName、Class.getMethod、以及 Method.invoke 这三个操作。其中,Class.forName 会调用本地方法,Class.getMethod 则会遍历该类的公有方法,如果没有匹配到,它还将遍历父类的公有方法,可想而知,这两个操作都非常耗时。下面就是 Method.invoke 调用本身的开销了,首先是 invoke 方法的参数是一个可变长参数,也就是构建一个 Object 数组存参数,这也同时带来了基本数据类型的装箱操作,在 invoke 内部会进行运行时权限检查,这也是一个损耗点。普通方法调用可能有一系列优化手段,比如方法内联、逃逸分析,而这又是反射调用所不能做的,性能差距再一次被放大。 优化反射调用,可以尽量避免反射调用虚方法、关闭运行时权限检查、可能需要增大基本数据类型对应的包装类缓存、如果调用次数可知可以关闭 Inflation 机制,以及增加内联缓存记录的类型数目。 @@ -213,13 +213,13 @@ Collections.copy(List dest, List src); #### JVM 是如何实现异常的? -在 Java 中,所有的异常都是 Throwable 类或其子类,它有两大子类 Error 和 Exception。 当程序触发 Error 时,它的执行状态已经无法恢复,需要终止线程或者终止虚拟机,常见的比如内存溢出、对栈溢出等;Exception 又分为两类,一类是受检异常,比如 IOException,一类是运行时异常 RuntimeException,比如空指针、数组越界等。 +在 Java 中,所有的异常都是 Throwable 类或其子类,它有两大子类 Error 和 Exception。 当程序触发 Error 时,它的执行状态已经无法恢复,需要终止线程或者终止虚拟机,常见的比如内存溢出、堆栈溢出等;Exception 又分为两类,一类是受检异常,比如 IOException,一类是运行时异常 RuntimeException,比如空指针、数组越界等。 接下来我会从三个方面阐述这个问题。 -首先是,异常实例的构造十分昂贵。这是由于在构造异常实例时,JVM 需要生成该异常的栈轨迹,该操逐一访问当前线程的 Java 栈桢,并且记录下各种调试信息,包括栈桢所指向方法的名字、方法所在的类名以及方法在原代码中的位置等信息。 +首先是,异常实例的构造十分昂贵。这是由于在构造异常实例时,JVM 需要生成该异常的栈轨迹,该操作逐一访问当前线程的 Java 栈桢,并且记录下各种调试信息,包括栈桢所指向方法的名字、方法所在的类名以及方法在源代码中的位置等信息。 -其次是,JVM 捕获异常需要异常表。每个方法都有一个异常表,异常表中的每一个条目都代表一个异常处理器,并且由 from、to、target 指针及其异常类型所构成。form-to 其实就是 try 块,而 target 就是 catch 的起始位置。当程序触发异常时,JVM 会坚持触发异常的字节码的索引值落到哪个异常表的 from-to 范围内,然后再判断异常类型是否匹配,匹配就开始执行 target 处字节码处理该异常。 +其次是,JVM 捕获异常需要异常表。每个方法都有一个异常表,异常表中的每一个条目都代表一个异常处理器,并且由 from、to、target 指针及其异常类型所构成。form-to 其实就是 try 块,而 target 就是 catch 的起始位置。当程序触发异常时,JVM 会检测触发异常的字节码的索引值落到哪个异常表的 from-to 范围内,然后再判断异常类型是否匹配,匹配就开始执行 target 处字节码处理该异常。 最后是 finally代码块的编译。我们知道 finally 代码块一定会运行的(除非虚拟机退出了)。那么它是如何实现的呢?其实是一个比较笨的办法,当前 JVM 的做法是,复制 finally 代码块的内容,分别放在所有可能的执行路径的出口中。