0%

Java中一般不太为新人所知的一些技术点

一般比较容易接触的点

Java对象结构

JVM中的Java数据类型

JVM的java方法栈中的数据结构大致的分为两类:

  • 内置类型 (一说也叫原子类型)
    • 内置类型指的是java语言规范规定的8个类型,即 boolean, byte, char, short, int, long, float, double
    • 内置类型依据不同的类型在栈帧中占用不同的空间, 他们在堆中存储时按照缩进规则进行padding, 在本地变量表中时, 32位及以下的类型占用一个槽(Variable Slot), double 和 long 占用两个。
  • 对象类型 (一说也叫做引用类型, 这里不计较返回地址类型)

指针压缩

Java代码是如何运行的

java代码的执行大概分为两大类三种类型。

  • 对于本地代码

    这里说的本地代码指的是JVM通过JNI机制调用的本地代码。

  • 对于Java字节码

    对于java字节码需要分两个情形来讨论。

    • 解释执行java字节码
    • 运行JIT代码

类加载和执行过程

装载 -> 链接 -> 初始化

如上过程就是一个类被虚拟机发现并到可用的全部过程, 其中每个步骤执行不同的操作。

类装载过程

类加载器

类加载器是负责加载class到JVM中的组件

在java9之前, 类加载器基本可以分为以下几类:

  • 启动类加载器 (bootstrap class loader)

    使用与JVM相同的实现语言编写(Hotspot的话是c++), 它是唯一一个java对象的类加载器, 同时它也是唯一一个不需要其他类加载器加载自身的加载器, 由虚拟机负责加载它。 j9以前负责加载最基础的类库 e.g. JRE的lib目录下的类, 通过虚拟机参数 -Xbootclasspath 指定的类。

  • 扩展类加载器(extension class loader)

    它的父加载器是bootstrap加载器, 同时它负责加载那些次一级的重要的类, e.g. JRE的 lib/ext目录下的jar包中的类, java.ext.dirs系统变量指定的类

  • 应用类加载器(application class loader)

    它的父加载器是extension加载器, 同时它负责加载应用程序目录下的类和由虚拟机参数 -cp/-classpath 和系统变量 java.class.path指定目录的类的加载。

  • 自定义类加载器

    自定义类加载器必须继承java.lang.ClassLoader。由于双亲委派模型,它只能负责加载它的祖先类加载器不能加载的类。

Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader), Java SE 中除了少数几个关键模块(如java.base)是由启动类加载器加载外, 其他模块都是由平台类加载器加载。

双亲委派模型

由于包含但不限于安全角度的考量,java的类加载器不可以随意决定加载类,java的各类加载器职能明确, 他们基本都有自己固定的职责, 例如启动类加载器只负责加载基础的重要的类(不然随意一个类加载器加载一个来源不明的Object类或者String类之类的重要类可就玩完了)。

类加载器基本遵循一个原则, 当收到加载一个类的委托时(这个委托可能是虚拟机发起, 也可能是其他类加载器发起), 需要先去委托自己的父类加载器尝试加载, 如果自己的父类加载器不能加载, 那么则由自己加载, 否则是用父类加载器加载。 这个原则, 就是双亲委派

定义加载器和初始加载器 (这是俩名词, 不是动词)

类加载的时机

  1. JVM采用按需加载类的模式, 类似于脚本语言中的缺页加载, 当JVM需要一个类的结构信息时, 发现它缺少, 才会去尝试加载它。

  2. 方法字节码加载, 一些类库的功能可以提供类加载的操作, 例如反射机制, 例如 ClassLoader.load()等。

最初的过程

JVM是按需加载的类, 那么最初的一个类是谁, 如果我们不把引导类加载器(bootstrap class loader)的初始化考虑在内, 那么第一个被加载的类由外部参数指定(就是那个包含 static void main(String[])方法的类, 暂且我们叫他Main类。在Main类的链接和初始化过程中, 可能级联的需要装载更多的类。在Main类初始化完成后, JVM从它的main方法开始执行字节码, 在执行过程中可能再次触发类加载。

数组

数组是一类特殊的类, 它不由类加载器创建(当然, 也就不用编译器生成), 而是有JVM来创建。

数组类的二进制名规则为对于N维数组, 则以N个[作为前缀, 后接元素类型的名称构成名字。

运行时常量池

运行时常量池区别与常量池字符串常量池 概念

其中, 常量池表是Java类文件结构中的一个区块, 运行时常量池是该结构在类加载后在JVM中维护的一个关联数据结构。每一个类实例在链接后都会有一个自己的运行时常量池。

最容易搞混的是 字符串常量池, 包括很多教材都弄错了, 它是java.lang.String类维护的一个私有的类似于KV结构的常量池, String.intern方法就使用了该结构的数据, 它是使用本地代码来维护的,

它用于在大量重复字符串时优化内存用量(例如某业务中大量使用全国地名, 性别字符串等... 使用intern能节省真的不少内存)。但是intern方法是线程安全的,所以, 在高频代码中我们应该把它的竞争特性考虑在编码中。 (未验证)

这个内容在JVM规范5.1节中介绍

更高级一些的加载约束

JVM规范5.3.4小节

JVM如何调用Java函数

JVM执行java字节码产生函数调用。调用的指令有:

指令 用途 效果
invokespecial 用以调用私有方法和final方法 (大意) ---------
invokestatic 用以调用类的静态方法 ---------
invokevirtual 用以调用一般的类成员方法 ---------
invokeinterface 用以调用接口方法 ---------
invokedynamic 用以调用动态调用点 ---------

其中, 就调用效率而言

invokestatic > invokespecial > invokevirtual > invokeinterface > invokedynamic

  • invokestatic 采用静态绑定, 在方法进行调用点解析一次之后无需再次解析, 每次调用都直接跳转到调用点, 相比于invokespecial因为静态方法无需this指针, 所以并不会将调用者对象压栈。

  • invokespecial 采用静态绑定, 类似于invokestatic, 相比后者, 它需要一个this指针, 所以需要对调用的主题进行压栈。 (我本来以为它只能用于对私有或者final方法的调用的, 但是看了虚拟机标准之后发现它可以用于调用protected的方法, 还有一系列的查找规则, 但是我没能实现出这些场景, 还是简单以我以上的认为吧, 八成应该都是正确的。)

  • invokevirtual 采用类似于虚表的方式将方法列出, 然后再在调用时根据第一个操作数去查找虚表,在虚表中匹配合适的方法产生调用。这其中比之invokespecial至少多出一个查表的查询操作。 (未经验证的资料指出, jvm会通过记录虚表索引的方式来进行查找优化,使每次调用不必进行搜索,这个有待考证,我想因为泛华的类型去查找最具体的方法,使用最直接的方法应该是不行的,实际测试效率也是这样, 所以对这个说法存疑)

  • invokeinterface 采用动态绑定, 类似于invokevitual, (它不需要检查方法的访问权限, 但是可能一个类同时实现多个接口的原因, 它至少每次调用都去查表, 所以我认为它比invokevirtual更慢)。

  • invokedynamic 采用程序自定义绑定, 每次这个指令出现的位置都成为一个动态调用点(dynamic call site), 它执行引导代码等一系列过程完成绑定, 然后执行该调用点。对于极少数的情况虚拟机会进行优化, 使之不需要每次执行这个指令都进行绑定, 但是在大多数情况下, 每次调用都需进行引导绑定。它复杂的一笔, 所以效率最低。

    invokedynamic在jvm7首次加入(但是jdk7版本的javac编译器并不能生成含有该指令的字节码)供运行在jvm上的其他基于字节码的动态语言使用, java8中javac首次能生成包含该指令的字节码, 用以实现lanmbda表达式。

就实际工程上来说, 这些调用方式的额外开销几乎可以忽略, 但是应该知道。

JVM是怎么实现invokedynamic的

这个问题单独写了一篇, 这里 指向引用

Java内存模型

java内存模型大概有7条happens-before规则, 不必一一记住,因为都挺简单直观。

这里需要知道的是, 这里的happens-before规则所确定的不是代码执行顺序而是可见性保证。

举个栗子:

1
2
int a = 10;
int b = a;
java 内存模型规则不保证 a=10 代码一定在 b=a之前执行, 而是保证, 在执行b=a代码的时候, a=10 这个值已经可见。例子有点极端, 但是没有错误。在更复杂一点的场景下可以阐述这个事实。

更深一层的点

即时编译 JIT

编译器类型

  • C1编译器
    • j7之前hotspot在-client模式默认使用的编译器, 编译效率快, 但是目标代码执行效率较低。
  • C2编译器
    • j7之前hotspot在-server模式默认使用的编译器, 编译效率低, 但是生成的目标代码执行效率高。
  • Graal编译器
    • j10采用的实验性质的编译器, 它本身使用java编写, 运行于jvm环境, 自身也可受到jit的优化。相较于C2, 它采用更加激进的优化策略。编译效率未测试,但是编译的目标代码不比C2的编译成果效率低。

分层编译

方法内联

HotSpot虚拟机的intrinsic

逃逸分析

基于逃逸分析做的优化

  1. 锁消除
  • 如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没有意义。这是因为其他线程并不能获得该锁对象,因此也不可能对其进行加锁。在这种情况下,即时编译器可以消除对该不逃逸锁对象的加锁、解锁操作。

  • 实际上,传统编译器仅需证明锁对象不逃逸出线程,便可以进行锁消除。由于 Java 虚拟机即时编译的限制,上述条件被强化为证明锁对象不逃逸出当前编译的方法。

  1. 栈上分配(OSR)
  • 由于实现起来需要更改大量假设了“对象只能堆分配”的代码,因此 HotSpot 虚拟机并没有采用栈上分配,而是使用了标量替换这么一项技术。
  1. 标量替换
  • 所谓的标量,就是仅能存储一个值的变量,比如 Java 代码中的局部变量。与之相反,聚合量则可能同时存储多个值,其中一个典型的例子便是 Java 对象。 标量替换这项优化技术,可以看成将原本对对象的字段的访问,替换为一个个局部变量的访问。这些变量不再要求按照堆对象那样连续分配,甚至可以直接存储在寄存器中不占用内存,对象头则直接消失。

Java Agent与字节码注入

可以不知道的点

即时编译器的中间表达形式

volatile 字段的虚共享