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类之类的重要类可就玩完了)。
类加载器基本遵循一个原则, 当收到加载一个类的委托时(这个委托可能是虚拟机发起, 也可能是其他类加载器发起), 需要先去委托自己的父类加载器尝试加载, 如果自己的父类加载器不能加载, 那么则由自己加载, 否则是用父类加载器加载。 这个原则, 就是双亲委派。
定义加载器和初始加载器 (这是俩名词, 不是动词)
类加载的时机
JVM采用按需加载类的模式, 类似于脚本语言中的缺页加载, 当JVM需要一个类的结构信息时, 发现它缺少, 才会去尝试加载它。
方法字节码加载, 一些类库的功能可以提供类加载的操作, 例如反射机制, 例如 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
2int a = 10;
int b = a;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
逃逸分析
基于逃逸分析做的优化
- 锁消除
如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没有意义。这是因为其他线程并不能获得该锁对象,因此也不可能对其进行加锁。在这种情况下,即时编译器可以消除对该不逃逸锁对象的加锁、解锁操作。
实际上,传统编译器仅需证明锁对象不逃逸出线程,便可以进行锁消除。由于 Java 虚拟机即时编译的限制,上述条件被强化为证明锁对象不逃逸出当前编译的方法。
- 栈上分配(OSR)
- 由于实现起来需要更改大量假设了“对象只能堆分配”的代码,因此 HotSpot 虚拟机并没有采用栈上分配,而是使用了标量替换这么一项技术。
- 标量替换
- 所谓的标量,就是仅能存储一个值的变量,比如 Java 代码中的局部变量。与之相反,聚合量则可能同时存储多个值,其中一个典型的例子便是 Java 对象。 标量替换这项优化技术,可以看成将原本对对象的字段的访问,替换为一个个局部变量的访问。这些变量不再要求按照堆对象那样连续分配,甚至可以直接存储在寄存器中不占用内存,对象头则直接消失。