2024年Android最新一文汇总JVM所有知识点(二)(1),大厂经典高频面试题体系化集合

总结

现在新技术层出不穷,如果每次出新的技术,我们都深入的研究的话,很容易分散精力。新的技术可能很久之后我们才会在工作中用得上,当学的新技术无法学以致用,很容易被我们遗忘,到最后真的需要使用的时候,又要从头来过(虽然上手会更快)。

我觉得身为技术人,针对新技术应该是持拥抱态度的,入了这一行你就应该知道这是一个活到老学到老的行业,所以面对新技术,不要抵触,拥抱变化就好了。

Flutter 明显是一种全新的技术,而对于这个新技术在发布之初,花一个月的时间学习它,成本确实过高。但是周末花一天时间体验一下它的开发流程,了解一下它的优缺点、能干什么或者不能干什么。这个时间,并不是我们不能接受的。

如果有时间,其实通读一遍 Flutter 的文档,是最全面的一次对 Flutter 的了解过程。但是如果我们只有 8 小时的时间,我希望能关注一些最值得关注的点。

(跨平台开发(Flutter)、java基础与原理,自定义view、NDK、架构设计、性能优化、完整商业项目开发等)

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

这其实也是Java中的一颗语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段完成。 这种方式只能实现语句基本块级别的条件编译,而没有办法实现根据条件调整整个Java类的结构。

8.3.4 其他语法糖

除上述泛型、自动装箱、自动拆箱、循环遍历、变长参数和条件编译外,还有一些其他的语法糖,一一来简单看一下。

8.3.4.1 内部类

在使用非静态内部类时,内部类自动持有外部类的引用。而且编译器会为内部类生成一个新的class文件。

public class InnerClass {

public void test() {

Builder builder = new Builder();

Runnable runnable = new Runnable() {

@Override

public void run() {

System.out.println(builder);

}

};

runnable.run();

}

public class Builder {

private String name;

}

}

上面是一段简单的代码,编译后会生成3个class文件InnerClass$1.classInnerClass$Builder.classInnerClass.class。它们编译之后的class反编译出来的代码分别如下:

//InnerClass$1.class

class InnerClass$1 implements Runnable {

final InnerClass this$0;

final InnerClass.Builder val$builder;

InnerClass$1(InnerClass this$02, InnerClass.Builder builder) {

this.this$0 = this$02;

this.val$builder = builder;

}

public void run() {

System.out.println(this.val$builder);

}

}

//InnerClass$Builder.class

public class InnerClass$Builder {

private String name;

final InnerClass this$0;

public InnerClass$Builder(InnerClass this$02) {

this.this$0 = this$02;

}

}

//InnerClass.class

public class InnerClass {

public void test() {

new 1(this, new Builder(this)).run();

}

}

从上面我们可以得出一些结论:

  1. 外部类的引用通过构造方法传进去的,内部类一直持有着

  2. 非静态内部类里面使用了外部的数据,也是通过构造方法传进去的

  3. 非静态内部类会自动生成一个新的class文件

8.3.4.2 枚举类

其实enum就是一个普通的类,它继承自java.lang.Enum类。在JVM字节码文件结构中,并没有枚举这个类型,编译器会在编译期将其编译成一个普通的类。

public enum Fruit {

APPLE,ORINGE

}

//编译之后

//继承java.lang.Enum并声明为final

public final class Fruit extends Enum

{

public static Fruit[] values() {

return (Fruit[])$VALUES.clone();

}

public static Fruit valueOf(String s) {

return (Fruit)Enum.valueOf(Fruit, s);

}

private Fruit(String s, int i) {

super(s, i);

}

//枚举类型常量

public static final Fruit APPLE;

public static final Fruit ORANGE;

private static final Fruit $VALUES[];//使用数组进行维护

static {

APPLE = new Fruit(“APPLE”, 0);

ORANGE = new Fruit(“ORANGE”, 1);

$VALUES = (new Fruit[] {

APPLE, ORANGE

});

}

}

8.3.4.3 数值字面量

Java支持的数值字面量:十进制、八进制(整数之前加0)、十六进制(整数之前加0x或0X)、二进制(整数之前加0b或0B)。在JDK7中,数值字面量的数字之间是允许插入任意多个下划线的,本身没有意义,只为方便阅读。

public class Test {

public static void main(String[] args) {

//十进制

int a = 10;

//二进制

int b = 0B1010;

//八进制

int c = 012;

//十六进制

int d = 0XA;

double e = 12_234_234.23;

System.out.println(“a:”+a);

System.out.println(“b:”+b);

System.out.println(“c:”+c);

System.out.println(“d:”+d);

System.out.println(“e:”+e);

}

}

上面一段示例代码在编译之后是下面这样:

public class Test {

public Test() {

}

public static void main(String args[]) {

int a = 10;

//编译器已经将二进制,八进制,十六进制数转换成了10进制数

int b = 10;

int c = 10;

int d = 10;

//编译器已经将下滑线删除

double e = 12234234.23D;

//字符串+号替换成了StringBuilder的append

System.out.println((new StringBuilder()).append(“a\uFF1A”).append(a).toString());

System.out.println((new StringBuilder()).append(“b\uFF1A”).append(b).toString());

System.out.println((new StringBuilder()).append(“c\uFF1A”).append©.toString());

System.out.println((new StringBuilder()).append(“d\uFF1A”).append(d).toString());

System.out.println((new StringBuilder()).append(“e\uFF1A”).append(e).toString());

}

}

在编译之后,全部都转换成了十进制,下划线也没了。同时,字符串+号替换成了StringBuilder的append。

8.3.4.4 对枚举和字符串的switch支持

switch对枚举和String的支持原理其实是差不多的。switch关键字原生只能支持整数类型,如果switch后面是String类型的话,编译器会将其转换成该字符串的hashCode的值,然后switch就通过这个hashCode的值进行case。

如果switch后面是Enum类型,则编译器会将其转换为枚举定义的下标,也还是整数类型。

String str = “world”;

switch(str) {

case “hello”:

System.out.println(“hello”);

break;

case “world”:

System.out.println(“world”);

break;

default:

break;

}

编译之后的class再反编译之后的代码:

String str = “world”;

String s;

switch((s = str).hashCode()) {

default:

break;

case 99162322:

//再次通过equals方法进行判断,因为不同字符串的hashCode值是可能相同的,比如“Aa”和“BB”的hashCode就是一样的

if(s.equals(“hello”))

System.out.println(“hello”);

break;

case 113318802:

if(s.equals(“world”))

System.out.println(“world”);

break;

}

8.3.4.5 try语句中定义和关闭资源

当一个外部资源的句柄对象实现了AutoCloseable接口,JDK 7中便可以利用try-with-resource语法更优雅的关闭资源。

try (FileInputStream inputStream = new FileInputStream(new File(“test”))) {

System.out.println(inputStream.read());

} catch (IOException e) {

throw new RuntimeException(e.getMessage(), e);

}

当这个try-catch代码块执行完毕后,Java会确保外部资源的close方法被调用。代码瞬间非常简洁,但是这只是语法糖,并不是JVM新增的功能。下面是编译之后的代码

try {

FileInputStream inputStream = new FileInputStream(new File(“test”));

Throwable var2 = null;

try {

System.out.println(inputStream.read());

} catch (Throwable var12) {

var2 = var12;

throw var12;

} finally {

if (inputStream != null) {

if (var2 != null) {

try {

inputStream.close();

} catch (Throwable var11) {

var2.addSuppressed(var11);

}

} else {

inputStream.close();

}

}

}

} catch (IOException var14) {

throw new RuntimeException(var14.getMessage(), var14);

}

编译器帮我们做了关闭资源的操作。

8.3.4.6 Lambda表达式

Lambda表达式用着很舒服,代码看着也简洁。它其实也是语法糖,由编译器推断并将其转换成常规的代码。

public class LambdaTest{

public static void main(String[] args) {

List list = new ArrayList();

list.add(“I”);

list.forEach( e -> System.out.println(“输出:”+e));

}

}

反编译后的代码:

public class LambdaTest {

public static void main(String[] arrstring) {

ArrayList arrayList = new ArrayList();

arrayList.add(“I”);

arrayList.forEach((Consumer)LambdaMetafactory.metafactory(null, null, null, (Ljava/lang/Object;)V, lambda$main$0(java.lang.String ), (Ljava/lang/String;)V)());

}

private static /* synthetic */ void lambda$main$0(String string) {

System.out.println(“\u6748\u64b3\u56ad:” + string);

}

}

上面的Lambda表达式最终是被换成了Consumer(一个接口),然后在forEach方法里面循环调用Consumer的accept()方法。

9. 后端编译与优化


9.1 概述

在前端编译器中,“优化”手段主要用于提升程序的编码效率,之所以把javac这类将Java代码变为字节码的编译器称作前端编译器,是因为它只完成了从程序到抽象语法树或中间字节码的生成,而在此之后,还有一组内置于Java虚拟机内部的后端编译器来完成代码优化以及从字节码生成本地机器码的过程,即之前多次提到的即时编译器或提前编译器,这个后端编译器的编译速度及编译结果质量高低,是衡量Java虚拟机性能最重要的一个指标。

本节中提及的即时编译器都是特指HotSpot虚拟机内置的即时编译器,虚拟机也是特指HotSpot虚拟机

9.2 即时编译器

目前主流的两款商用Java虚拟机(HotSpot、OpenJ9)里,Java程序最初都是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”,为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。

9.2.1 解释器与编译器

主流的商用Java虚拟机内部都同时包含解释器与编译器,解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。

HotSpot虚拟机内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别称为客户端编译器和服务端编译器,或者简称为C1编译器和C2编译器。第三个是在JDK10才出现的、长期目标是替代C2的Graal编译器,Graal编译器目前还处于实验状态。

无论采用的编译器是客户端编译器还是服务端编译器,解释器与编译器搭配使用的方式在虚拟机中被称为混合模式。用户也可以控制让虚拟机运行于解释模式,这样编译器不介入工作,全部代码由解释方式执行。也可以强制虚拟机运行于编译模式,这时将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。

为了在程序启动速度与运行效率之间达到最佳平衡,HotSpot虚拟机在编译子系统中加入了分层编译的功能。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次。

9.2.2 编译对象与触发条件

在运行过程中会被即时编译器编译的目标是热点代码,主要有两类:

  • 被多次调用的方法

  • 被多次执行的循环体

对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。

要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为热点探测,其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主流的热点探测判定方式有两种:

  1. 基于采样的热点探测:周期性地检查各个线程的调用栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是热点方法。

  2. 基于计数器的热点探测:采用这种方法的虚拟机会为每个方法建立计数器,统计方法的执行此时,如果执行次数超过一定的阈值就认为它是热点方法。

在HotSpot虚拟机中使用的是第二种基于计数器的热点探测方法,为了实现热点计数,HotSpot为每个方法准备了两类计数器:方法调用计数器和回边计数器(回边的意思是指在循环边界往回跳转)。

当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将该方法的调用计数器值加1,然后判断方法调用计数器与回边计数器之和是否超过方法调用计数器的阈值。

执行引擎默认不会同步等待编译请求完成,而是继续进行解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了,整个即时编译的交互过程如下图所示:

方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减,而这段时间被称为此方法的半衰周期。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的。

回边计数器,它的作用是统计一个方法中循环体代码执行的次数。当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就将回边计数器的值加1,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。

9.2.3 编译过程

默认情况下,无论是方法调用产生的标准编译请求,还是栈上替换编译请求,虚拟机在编译器还未完成编译之前,都仍然将按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行。

在后台执行编译的过程中,对于客户端编译器来说,它是一个相对简单快速的三段式编译器,主要的关注点在于局部的优化,而放弃了许多耗时较长的全局优化手段

  1. 第一阶段,一个平台独立的前端将字节码构造成一种高级中间代码(HIR)表示

  2. 第二阶段,一个平台相关的后台从HIR中产生低级中间代码(LIR)表示。

  3. 第三阶段,在平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器代码。

而服务端编译器则是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器。它会执行大部分经典的优化动作,如无用代码消除、循环展开、常量传播、消除公共子表达式等。

9.3 提前编译器

2013年,在Android中使用提前编译的ART横空出世。

9.3.1 提前编译的优劣得失

现在提前编译产生和对其的研究有着两条明显的分支,一条分支是做与传统C、C++编译器类似的,在程序运行之前把程序代码编译成机器码的静态翻译工作;另外一条分支是把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码(譬如公共库代码在被同一台机器其他Java进行使用)时直接把它加载进来使用。

先说第一条,这是传统的提前编译应用形式,它在Java中存在的加载直指即时编译的最大弱点:即时编译要占用程序运行时间与运算资源

如果是在程序运行之前进行的静态编译,耗时的优化就可以放心大胆地进行了。这也是ART打败Dalvik的主要武器之一,连副作用也是相似的。在Android 5.0和6.0版本,安装一个稍微大一点的Android应用就得很长时间,以至于从Android 7.0版本起重新启用了解释执行和即时编译(但这与Dalvik无关,它彻底凉了),等空闲时系统再在后台自动进行提前编译。

关于提前编译的第二条路径,本质是给即时编译器做缓存加速,去改善Java程序的启动时间,以及需要一段时间预热后才能达到最高性能的问题。

尽管即时编译在时间和运算资源方面的劣势是无法忽视的,但其依然有自己的优势:

  1. 性能分析制导优化:如果一个条件分支的某一条路径执行特别频繁,而其他路径鲜有问津,那就可以把热的代码集中放到一起,集中优化和分配更好的资源(分支预测、寄存器、缓存等)给它。

  2. 激进预测性优化:如果性能监控信息能够支持它做出一些正确的可能性很大但无法保证绝对正确的预测判断,就已经可以大胆地按照高概率的假设进行优化,万一真的走到罕见分支上,大不了退回到低级编译器甚至解释器上去执行,不会出现无法挽救的后果。

  3. 链接时优化:Java语言天生就是动态链接的,一个个class文件在运行期被加载到虚拟机内存当中,然后在即时编译器里产生优化后的本地代码。

9.4 编译器优化技术

编译器的目标虽然是做由程序代码翻译为本地机器码的工作,但其实难点并不在于能不能成功翻译出机器码,输出代码优化质量的高低才是决定编译器优秀与否的关键。

9.4.1 优化技术概览

即时编译器对这些代码优化变化是建立在代码的中间表示或者是机器码之上的,绝不是直接在Java源码上去做的,这里只是笔者为了方便讲解,使用了Java语言的语法来表示这些优化技术所发挥的作用。

先来个简单示例,这是优化前的原始代码:

static class B {

int value;

final int get() {

return value;

}

}

public void foo() {

y = b.get();

// …do stuff…

z = b.get();

sum = y + z;

}

首先,第一个要进行的优化是方法内联,它的主要目的有两个:一是去除方法调用的成本(如查找方法版本、建立栈帧等);二是为其他优化建立良好的基础。

//内联后的代码

public void foo() {

y = b.value;

// …do stuff…

z = b.value;

sum = y + z;

}

第二步进行冗余访问消除

public void foo() {

y = b.value;

// …do stuff…

z = y;

sum = y + z;

}

第三步进行复写传播,因为这段程序的逻辑之中没有必要使用一个额外的变量z,它与变量y是完全相等的,因此我们可以使用y来代替z。

public void foo() {

y = b.value;

// …do stuff…

y = y;

sum = y + y;

}

第四步进行无用代码消除,可能是永远不会执行的代码,也可能是完全没有意义的代码。

public void foo() {

y = b.value;

// …do stuff…

sum = y + y;

}

9.4.2 方法内联

方法内联是编译器最重要的优化手段。除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础。 方法内联的优化行为不过就是把目标方法的代码原封不动地“复制”到发起调用的方法之中,避免发生真实的方法调用而已。但实际上,虚拟机的实现内联过程非常复杂。

对于一个虚方法,编译器静态地去做内联的时候很难确定应该使用哪个方法版本。如有ParentB和SubB是两个具有继承关系的父子类型,并且子类重写了父类的get()方法,那么b.get()是执行父类的get()方法还是子类的get()方法,这应该是根据实际类型动态分派的,而实际类型必须在实际运行到这一行代码时才能确定,编译器很难在编译时得出绝对准确的结论。

为了解决虚方法的内联问题,Java虚拟机首先引入了一种名为类型继承关系分析(CHA)的技术,这是整个应用程序范围内的类型分析技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。编译器进行内联时就会分不同情况采取不同的处理:如果是非虚方法,那么直接进行内联;如果是虚方法,则先向CHA查询此方法在当前程序状态下是否真的有多个目标版本可供选择,如果查到只有一个版本,那就假设应用程序的全貌就是现在运行的这个样子来进行内联,这种内联被称为守护内联。如果加载了导致继承关系发生变化的新类,那么就必须抛弃已经编译的代码,退回到解释状态进行执行,或者重新进行编译。

假如向CHA查询出来的结果是该方法确实有多个版本的目标方法可供选择,那即时编译器还将进行最后一次努力,使用内联缓存的方式来缩短方法调用的开销。

所以,大多数情况下Java虚拟机进行的方法内联都是一种激进优化。

9.4.3 逃逸分析

逃逸分析是目前Java虚拟机中比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,,而是为其他优化措施提供依据的分析技术。

逃逸分析的基本原理:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。

如果能证明一个对象不会逃逸到方法或线程之外(别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化,如:

  • 栈上分配:如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不支持线程逃逸。

  • 标量替换:若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。把一个Java对象拆散,根据程序访问额情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。

  • 同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以完全地消除掉。

下面举个例子来分析一下逃逸分析是如何工作的,下面这些是伪代码:

// 完全未优化的代码

public int test(int x) {

int xx = x + 2;

Point p = new Point(xx, 42);

return p.getX();

}

第一步,将Point的构造函数和getX()方法进行内联优化:

// 步骤1:构造函数内联后的样子

public int test(int x) {

int xx = x + 2;

Point p = point_memory_alloc(); // 在堆中分配P对象的示意方法

p.x = xx; // Point构造函数被内联后的样子

p.y = 42

return p.x; // Point::getX()被内联后的样子

}

经过逃逸分析,发现在真个test()方法的范围内Point对象实例不会发生任何程序的逃逸,这样可以对它进行标量替换优化,把其内部的x和y直接置换出来,分解为test()方法内的局部变量,从而避免Point对象实例被实际创建,优化后如下:

// 步骤2:标量替换后的样子

public int test(int x) {

int xx = x + 2;

int px = xx;

int py = 42;

return px;

}

通过数据流分析,发现py的值其实对方法不会造成任何影响,那就可以去做无效代码消除得到最终优化结果:

// 步骤3:做无效代码消除后的样子

public int test(int x) {

return x + 2;

}

尽管目前逃逸分析技术仍在发展之中,未完全成熟,但它是即时编译器优化技术的一个重要前进方向,在日后的Java虚拟机中,逃逸分析技术肯定会支撑起一系列更实用、有效的优化技术。

9.4.4 公共子表达式消除

公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术:如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。如果这种优化仅限于程序基本块内,便可以称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除。

例如:

int d = (c * b) * 12 + a + (a + b * c);

其中c*bb*c是一样的表达式,而且再计算期间b与c的值是不变的,所以这条表达式可能被视为:

int d = E * 12 + a + (a + E);

这时编译器还可能进行另外一种优化——代数化简,在E本来就有乘法运算的前提下,把表达式变为:

int d = E * 13 + a + a;

9.4.5 数组边界检查消除

数组边界检查消除是即时编译器中的一项语言相关的经典优化技术。如果有一个数组foo[],在Java语言中访问数组元素f00[i]的时候系统将会自动进行上下界的范围检查,即i必须满足i>=0 && i<foo.length的访问条件,否则将抛出一个运行时异常:java.lang.ArrayIndexOutOfBoundsException

为了安全,数组边界检查肯定是要做的,但是数组边界检查是不是必须在运行期间一次不漏地进行则是可以商量的事情。常见的情况是,数组访问发生在循环之中,并且使用循环变量来进行数组的访问。如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,foo.length)之内,那么在循环中就可以把整个数组的上下界检查消除掉,可以节省很多次的条件判断操作。

Java要做很多检查判断,为了消除这些隐式开销,除了如数组边界检查优化这种尽可能把运行期检查提前到编译器完成的思路之外,还有一种避开的处理思路——隐式异常处理,Java中空指针检查和算术运算中除数为零的检查都采用了这种方案。

//伪代码 虚拟机访问foo.value

if (foo != null) {

return foo.value;

}else{

throw new NullPointException();

}

//隐式异常优化之后

try {

return foo.value;

} catch (segment_fault) {

uncommon_trap();

}

虚拟机会注册一个Segment Fault信号的异常处理器,务必注意这里是指进程层面的异常处理器,并非真的Java的try-catch语句的异常处理器。进入异常处理器的过程涉及进程从用户态转到内核态中处理的过程,结束后会再回到用户态,速度远比一次判空检查要慢得多。当foo极少为空的时候,隐式异常优化是值得的,但加入foo经常为空,这样的优化反而会让程序更慢。幸好HotSpot虚拟机足够聪明,它会根据运行期收集到的性能监控信息自动选择最合适的方案。

10. Java内存模型与线程


10.1 硬件的效率与一致性

由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现在计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高级缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但是它引入了一个新问题:缓存一致性。在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,这种系统成为共享内存多核系统。为了解决缓存一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。

除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有指令重排序优化。

10.2 Java内存模型

Java内存模型(JMM):屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

10.2.1 主内存与工作内存

Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。此处的变量与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。

Java内存模型规定所有的变量都存储在主内存中(此处的主内存与介绍物理硬件时提到的主内存名字一样,两者也可以类比,但物理上它仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(可与前面讲的高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

需要注意的是,volatile变量依然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一样。

10.2.2 内存间交互操作

关于主内存与工作内存之间的具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如果从工作内存同步回主内存这一类的实现细节,Java内存模型定义了以下8种操作来完成。Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许有例外)

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态

  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定

  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到现场的工作内存中,以便随后的load动作使用

  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中

  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每个虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作

  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作

  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用

  • write(写入):作用于主内存的变量,它把store操作工作内存中得到的变量的值放入主内存的变量中

10.2.3 对于volatile型变量的特殊规则

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制。

当一个变量被定义成volatile之后,它将具备两项特性:第一项是保证此变量对所有线程的可见性,这里的可见性是指当一个线程修改了这个变量的值,新值对于其他线程来说可以立即得知。第二个语义是禁止指令重排序优化

volatile变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的

下面是自增运算race++的javap反编译代码:

public static void increase();

Code:

Stack=2, Locals=0, Args_size=0

0: getstatic

3: iconst_1

4: iadd

5: putstatic

8: return

LineNumberTable:

line 14: 0

line 15: 8

当getstatic指令把race的值取到操作数栈顶时,volatile关键字保证了race的值在此时是正确的,但是在执行iconst_1、iadd这些指令的时候,其他线程可能已经把race的值改变了,而操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的race值同步回主内存之中。

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized、java.util.concurrent中的锁或原子类)来保证原子性:

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值

  • 变量不需要与其他的状态变量共同参与不变约束

下面的代码的场景就很适合使用volatile变量来控制并发:

volatile boolean shutdownRequested;

public void shutdown() {

shutdownRequested = true;

}

public void doWork() {

while (!shutdownRequested) {

// 代码的业务逻辑

}

}

可见性原理: 当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,这个lock指令第一个作用是将这个缓存中的变量回写到系统主存中;第二个作用是这个写内存的操作会使其他CPU里缓存了该内存地址的数据无效.但是就算回写到内存,如果其他处理器缓存的值还是旧的,还是有问题,为了保证各个处理器的缓存是一致的,就会实现一致性协议,即每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,如果内存地址被修改就会把当前处理器的缓存行设置为无效状态,当处理器对这个数据进行操作的时候,就会重新拉一份新的值.

有序性原理: 有序性是通过内存屏障来实现的,具体实现:

  1. 在volatile写操作的的前面插入一个StoreStore屏障,保障volatile写操作不会和之前的写操作重排序

  2. 在volatile写操作的后面插入一个StoreLoad屏障,保障volatile写操作不会和之后的读操作重排序

  3. 在volatile读操作的后面插入一个LoadLoad屏障+LoadSore屏障,保证volatile读操作不会和之后的读操作,写操作重排序

10.2.4 针对long和double型变量的特殊规则

对于64位的数据类型(long和double),允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的”long和double的非原子性协定“

10.2.5 原子性、可见性与有序性

Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的。

10.2.5.1 原子性

大致可以认为,基本数据类型的访问、读写都是具备原子性的。

如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。

10.2.5.2 可见性

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此。普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。可以说是volatile保证了多线程操作时的可见性,而普通变量则不能保证这一点。

除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized和final。同步块的可见性是由”对一个变量执行unlock操作之前,必须先把次变量同步回主内存中“这条规则获得的。而final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完毕,并且构造器没有把this引用传递出去,那么在其他线程中就能看见final字段的值。

10.2.5.3 有序性

Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指线程内似表现为串行的语义,后半句是指指令重排序现象和工作内存与主内存同步延迟现象。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由一个变量在同一时刻只允许一条线程对其进行lock操作这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

10.2.6 先行发生原则

先行发生原则:它是判断数据是否存在竞争,线程是否安全的非常有用的手段。

先行发生是Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,影响包括修改了内存中共享变量的值、发送了消息、调用了方法等。

下面是Java内存模型下一些天然的先行发生关系,这些先行发生关系无需任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,则它们就没有顺序性保障,虚拟机可以对它们随意进行重排序。

  • 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构

  • 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后。

  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后

  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作

  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行

  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生

  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始

  • 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论

时间先后顺序与先行发生原则之间基本没有因果关系,所以我们衡量并发安全问题的时候不要受时间顺序的干扰,一切以先行发生原则为准。

10.3 Java与线程

10.3.1 线程的实现

最后

在这里我和身边一些朋友特意整理了一份快速进阶为Android高级工程师的系统且全面的学习资料。涵盖了Android初级——Android高级架构师进阶必备的一些学习技能。

附上:我们之前因为秋招收集的二十套一二线互联网公司Android面试真题(含BAT、小米、华为、美团、滴滴)和我自己整理Android复习笔记(包含Android基础知识点、Android扩展知识点、Android源码解析、设计模式汇总、Gradle知识点、常见算法题汇总。)

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

先行发生是Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,影响包括修改了内存中共享变量的值、发送了消息、调用了方法等。

下面是Java内存模型下一些天然的先行发生关系,这些先行发生关系无需任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,则它们就没有顺序性保障,虚拟机可以对它们随意进行重排序。

  • 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构

  • 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后。

  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后

  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作

  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行

  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生

  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始

  • 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论

时间先后顺序与先行发生原则之间基本没有因果关系,所以我们衡量并发安全问题的时候不要受时间顺序的干扰,一切以先行发生原则为准。

10.3 Java与线程

10.3.1 线程的实现

最后

在这里我和身边一些朋友特意整理了一份快速进阶为Android高级工程师的系统且全面的学习资料。涵盖了Android初级——Android高级架构师进阶必备的一些学习技能。

附上:我们之前因为秋招收集的二十套一二线互联网公司Android面试真题(含BAT、小米、华为、美团、滴滴)和我自己整理Android复习笔记(包含Android基础知识点、Android扩展知识点、Android源码解析、设计模式汇总、Gradle知识点、常见算法题汇总。)

[外链图片转存中…(img-eaC9dYjO-1715671045382)]

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 20
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值