阅读 62

面试官:Java中实例对象存储在哪?

在面试时,遇到这个问题,先不要掉以轻心的一口回答在堆中,一般在java程序中,new的对象是分配在堆空间中的,但是实际的情况是,大部分的new对象会进入堆空间中,而并非是全部的对象,还有另外两个地方可以存储new的对象,我们称之为栈上分配以及TLAB

学习本章需要一些前置知识,这里我列一下:

1. JVM的类加载流程

2. JVM内存结构 / 堆分代结构

下面进入正题:

[toc]

理解Java编译流程

低级语言是计算机认识的语言、高级语言是程序员认识的语言。如何从高级语言转换成低级语言呢?这个过程其实就是编译。

不同的语言都有自己的编译器,Java语言中负责编译的编译器是一个命令:javac

通过javac命令将Java程序的源代码编译成Java字节码,即我们常说的.class文件。这也是我们所理解的编译.

但是.class并不是计算机能够识别的语言.要想让机器能够执行,需要把字节码再翻译成机器指令,这个过程是JVM来完成的.这个过程也叫编译.只是层次更深..

因此我们了解到,编译器可划分为前端(Front End)后端(Back End)

我们可以把将.java文件编译成.class的编译过程称之为前端编译。把将.class文件翻译成机器指令的编译过程称之为后端编译。

前端编译(Front End)

前端编译主要指与源语言有关但与目标机无关的部分,包括词法分析、语法分析、语义分析与中间代码生成。

例如我们使用很多的IDE,如eclipse,idea等,都内置了前端编译器。主要功能就是把.java代码转换成`.class字节码

后端编译(Back End)

后端编译主要指与目标机有关的部分,包括代码优化和目标代码生成等。

在后端编译中,通常都经过前端编译的处理,已经加工成.class字节码文件了 JVM通过解释字节码将其逐条读入并翻译为对应机器指令,读一条翻译一条,势必是分产生效率问题因此引入了JIT(just in time)

什么是JIT (Just in time)

当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。JIT会把部分“热点代码”翻译成本地机器相关的机器码,并进行优化,然后缓存起来,以备下次使用

在HotSpot虚拟机中内置了两个JIT编译器分别是:

- Client complier  [客户端]
- Server complier  [服务端]
复制代码

目前JVM中默认都是采用: 解释器+一个JIT编译器 配合的方式进行工作 即混合模式

下图是我机器上安装的JDK ,可以看出,使用的JIT是Server Complier, 解释器和JIT的工作方式是mixed mode

面试题:为何HotSpot虚拟机要实现两个不同的即时编译器?

HotSpot虚拟机中内置了两个即时编译器:Client Complier和Server Complier,简称为C1、C2编译器,分别用在客户端和服务端。目前主流的HotSpot虚拟机中默认是采用解释器与其中一个编译器直接配合的方式工作。程序使用哪个编译器,取决于虚拟机运行的模式。HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在Client模式或Server模式。

用Client Complier获取更高的编译速度,用Server Complier 来获取更好的编译质量。和为什么提供多个垃圾收集器类似,都是为了适应不同的应用场景。

编译器和解释器的优缺点以及实用场景

在JVM执行代码时,它并不是马上开始编译代码,当一段经常被执行的代码被编译后,下次运行就不用重复编译,此时使用JIT是划算的,但是它也不是万能的,比如说一些极少执行的代码在编译时花费的时间比解释器还久,这时就是得不偿失了

所以,解释器和JIT各有千秋:

解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率

  1. 当极少执行或者执行次数较少的JAVA代码使用解释器最优.
  2. 当重复执行或者执行次数较多的JAVA代码使用JIT更划算.

热点检测算法

要想触发JIT,首先需要识别出热点代码。目前主要的热点代码识别方式是热点探测(Hot Spot Detection),有以下两种:

1)基于采样的热点探测

采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这个方法就是“热点方法”。这种探测方法的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。

2) 基于计数器的热点探测

采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阀值,就认为它是“热点方法”。这种统计方法实现复杂一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对更加精确严谨。

那么在HotSpot虚拟机中使用的是哪个热点检测方式呢?

在HotSpot虚拟机中使用的是第二种,基于计数器的热点探测方法,因此它为每个方法准备了两个计数器

>1 方法调用计数器

顾名思义,就是记录一个方法被调用次数的计数器。

>2 回边计数器

是记录方法中的for或者while的运行次数的计数器。

在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。

对象栈上分配的优化

逃逸分析

逃逸分析是一种有效减少JAVA程序中 同步负载堆内存分配压力 的分析算法.Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到上.

public static StringBuffer method(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append("关注");
    sb.append("java宝典");
    return sb;
  	//此时sb对象从method方法逃出..
}
复制代码
public static String method(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append("关注");
    sb.append("java宝典");
    return sb.toString();
  	//此时sb对象 没有离开 作用域
}
复制代码
    public void globalVariableEscape(){
        globalVariableObject = new Object(); //静态变量,外部线程可见,发生逃逸
    }

    public void instanceObjectEscape(){
        instanceObject = new Object(); //赋值给堆中实例字段,外部线程可见,发生逃逸
    }
复制代码

在确定对象不会逃逸后,JIT将可以进行以下优化: 标量替换 同步消除 栈上分配

第一段代码中的sb就逃逸了,而第二段代码中的sb就没有逃逸。

在Java代码运行时,通过JVM参数可指定是否开启逃逸分析,

-XX:+DoEscapeAnalysis : 表示开启逃逸分析

-XX:-DoEscapeAnalysis : 表示关闭逃逸分析

-XX:+PrintEscapeAnalysis 开启打印逃逸分析筛选结果

从jdk 1.7开始已经默认开始逃逸分析

标量替换

允许将对象打散分配在栈上,比如若一个对象拥有两个字段,会将这两个字段视作局部变量进行分配。

逸分析只是栈上内存分配的前提,还需要进行标量替换才能真正实现。例:

public static void main(String[] args) throws Exception {
    long start = System.currentTimeMillis();
    for (int i = 0; i < 10000; i++) {
        allocate();
    }
    System.out.println((System.currentTimeMillis() - start) + " ms");
    Thread.sleep(10000);
}
public static void allocate() {
    MyObject myObject = new MyObject(2019, 2019.0);
}
public static class MyObject {
    int a;
    double b;
    MyObject(int a, double b) {
        this.a = a;
        this.b = b;
    }
}
复制代码

标量,就是指JVM中无法再细分的数据,比如int、long、reference等。相对地,能够再细分的数据叫做聚合量

Java虚拟机中的原始数据类型(int,long等数值类型以及reference类型等)都不能再进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解,那它称为聚合量,Java中最典型的聚合量是对象

如果逃逸分析证明一个对象不会被外部访问,并且这个对象是可分解的,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。拆散后的变量便可以被单独分析与优化,可以各自分别在栈帧或寄存器上分配空间,原本的对象就无需整体分配空间了

仍然考虑上面的例子,MyObject就是一个聚合量,因为它由两个标量a、b组成。通过逃逸分析,JVM会发现myObject没有逃逸出allocate()方法的作用域,标量替换过程就会将myObject直接拆解成a和b,也就是变成了:

static void allocate() {
    int a = 2019;
    double b = 2019.0;
}
复制代码

可见,对象的分配完全被消灭了,而int、double都是基本数据类型,直接在栈上分配就可以了。所以,在对象不逃逸出作用域并且能够分解为纯标量表示时,对象就可以在栈上分配

  • 开启标量替换 (-XX:+EliminateAllocations)

标量替换的作用是允许将对象根据属性打散后分配在栈上,默认该配置为开启

同步消除(锁消除)

如果同步块所使用的锁对象通过逃逸分析被证实只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这个取消同步的过程就叫同步省略,也叫锁消除

例子:

public void f() {
    Object java_bible = new Object();
    synchronized(java_bible) {
        System.out.println(java_bible);
    }
}
复制代码

在经过逃逸分析后,JIT编译阶段会被优化成:

public void f() {
    Object java_bible = new Object();
    System.out.println(java_bible);  //锁被去掉了.
}
复制代码

如果JIT经过逃逸分析之后发现并无线程安全问题的话,就会做锁消除。

栈上分配

通过逃逸分析,我们发现,许多对象的生命周期会随着方法的调用开始而开始,方法的调用结束而结束,很多的对象的作用域都不会逃逸出方法外,对于此种对象,我们可以考虑使用栈上分配,而不是在堆中分配.

因为一旦分配在堆空间中,当方法调用结束,没有了引用指向该对象,该对象就需要被gc回收,而如果存在大量的这种情况,对gc来说反而是一种负担。

JVM提供了一种叫做栈上分配的概念,针对那些作用域不会逃逸出方法的对象,在分配内存时不在将对象分配在堆内存中,而是将对象属性打散后分配在栈(线程私有的,属于栈内存,标量替换)上,这样,随着方法的调用结束,栈空间的回收就会随着将栈上分配的打散后的对象回收掉,不再给gc增加额外的无用负担,从而提升应用程序整体的性能

那么问题来了,如果栈上分配失败了怎么办?

对象的内存分配

创建个对象有多种方法: 比如 使用new , reflect , clone 不管使用哪种 ,我们都要先分配内存

我们拿new 来举个例子:

T t = new T()
  
class T{
	int m = 8;
}

//javap
  
0 new #2<T>		//new作用在内存申请开辟一块空间 new完之后m的值为 0
3 dup 
4 invokespecial #3 <T.<init>>  
7 astore_1	
8 return
复制代码

那么它是怎么分配的呢?

当我们使用new创建对象后代码开始运行后,虚拟机执行到这条new指令的时候,会先检查要new的对象对应的类是否已被加载,如果没有被加载则先进行类加载,检查通过之后,就需要给对象进行内存分配,分配的内存主要用来存放对象的实例变量

为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来

根据内存连续和不连续的情况,JVM使用不同的分配方式.

  • 连续: 指针碰撞
  • 不连续:空闲列表

指针碰撞(Serial、ParNew等带Compact过程的收集器) 假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump the Pointer)。

空闲列表(CMS这种基于Mark-Sweep算法的收集器) 如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。

无论那种方式,最终都需要确定出一块内存区域,用于给新建对象分配内存。对象的内存分配过程中,主要是对象的引用指向这个内存区域,然后进行初始化操作,那么在并发场景之中,如果多线程并发去堆中获取内存区域,怎么保证内存分配线程安全性.

解决堆内存分配的并发问题

保证分配过程中的线程安全有两种方式:

  • CAS
  • TLAB

CAS

CAS:采用CAS机制,配合失败重试的方式保证线程安全性

CAS对于内存的控制是使用重试机制,因此效率比较低,目前JVM使用的是TLAB方式,我们着重介绍TLAB.

TLAB

TLAB:每个线程在Java堆中预先分配一小块内存,然后再给对象分配内存的时候,直接在自己这块"私有"内存中分配,当这部分区域用完之后,再分配新的"私有"内存,注意这个私有对于创建对象时是私有的,但是对于读取是共享的.

TLAB (Thread local allcation buffer ) 在“分配”这个动作上是线程独占的,至于在读取、垃圾回收等动作上都是线程共享的。在对象的创建时,首先尝试进行栈上分配,如果分配失败,会使用TLAB尝试分配,如果失败查看是否是大对象,如果是大对象直接进入老年代,否则进入新生代(Eden).这里我总结了一张流程图,如下:

我们可以总结出: 创建大对象和创建多个小对象相比,多个小对象的效率更高

不知道大家有没有注意到,TLAB分配空间,每个线程在Java堆中预先分配一小块内存,他们在堆中去抢地盘的时候,也会出现并发问题,但是对于TLAB的同步控制和我们直接在堆中分配相比效率高了不少(不至于因为要分配一个对象而锁住整个堆了).

总结

为了保证Java对象的内存分配的安全性,同时提升效率,每个线程在Java堆中可以预先分配一小块内存,这部分内存称之为TLAB(Thread Local Allocation Buffer),这块内存的分配时线程独占的,读取、使用、回收是线程共享的。

虚拟机是否使用TLAB 可以通过 -XX:+/-UseTLAB 参数指定

欢迎关注公众号:java宝典 领取更多教程福利

文章分类
后端
文章标签