4 minute read

运行时数据区(Java内存区域)可分为五个部分,方法区、堆、程序计数器、虚拟机栈、本地方法栈,其中堆和方法区线程共享。运行时数据区与JVM一一对应,也就是说一个JVM只有一个运行时数据区。

QQ截图20210427164232.jpg

alt

方法区(重点)

//Person放到方法区 person放到虚拟机栈 new Person()放到堆
Person person=new Person();

方法区看作是一块独立于Java堆的内存空间。线程共享。方法区的大小决定了系统可以保存多少个类,关闭JVM就会释放内存。当加载的类过多或者Tomcat部署的工程过多(30-50)或者大量动态的生成反射类的时候会报OOM。

JDK7之前把方法区称为永久代,之后称为元空间。(类似接口与具体实现,方法区是接口,元空间是具体实现)

元空间与永久代的最大区别在于:元空间使用本地内存,永久代使用虚拟机设置的内存

方法区存储类型信息、常量、静态变量、即时编译器编译后的代码缓存

常量池可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型

为什么需要常量池?一个Java字节码文件需要的数据会很大以至于不能直接存到字节码里,换一种方式可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池。

设置方法区大小

默认值依赖于平台。windows下 -XX:MetaspaceSize是21M,意思是当元空间大小触及到这个水位线的时候,Full GC将会被触发并卸载没用的类,然后高水位线将会重置。新的高水位线取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过最大值时,适当提高该值,如果释放的空间过多,则适当降低该值。如果初始化的高水位线设置过低,上述高水位线调整频繁,建议将该值设置为相对较高的值。

-XX:MaxMetaspaceSize的值是-1,即没有限制。

方法区运行时常量池

  • 方法区内部包含了运行时常量池
  • 字节码文件内部包含了常量池表。字节码中的常量池表经过类加载子系统加载到内存后,放在了方法区,被称为运行时常量池。
  • 存储引用

Hotspot方法区变化

JDK版本 变化
jdk1.6及之前 有永久代,静态变量存放在永久代上
jdk1.7 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中
jdk1.8及之后 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆

方法区垃圾回收

回收效果一般,但是又不得不回收(费力不讨好)

主要回收两部分:常量池中废弃的常量和不再使用的类型。

回收策略:只要常量池中的常量没有被任何地方引用,就可以被回收。

堆(重点)

线程共享(堆空间并不是全都共享,其中有个线程私有的缓冲区TLAB,Thread Local Allocation Buffer),大小可以调节,处于物理上不连续的内存空间,但是逻辑上视为连续。

设置启动参数-Xms10m -Xms20m即堆的初始空间为10m,最大空间为20m,运行程序并且用Java VisualVm(在JDK的BIN目录下)观看

QQ截图20210425162411.jpg

所有对象实例以及数组都应当在运行时分配在堆上

数组和对象可能永远不会存储在栈上,因为在栈帧中保存引用,这个引用指向对象或数组在堆中的位置。

在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

堆是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域

堆内存细分

  • Java7之前逻辑上分为新生代+老年代+永久代
  • Java8之后逻辑上分为新生代+老年代+元空间(视为方法区的逻辑实现)

新生代又可分为伊甸园区、S0区、S1区。

-XX:+PrintGCDetails 打印细节

    System.out.println("start");
    String s="abc";
    System.out.println(s);
    System.out.println("end");
start
abc
end
Heap
  //新生代
 PSYoungGen      total 2560K, used 1933K [0x00000000ff980000, 0x00000000ffc80000, 0x0000000100000000)
  eden space 2048K, 94% used [0x00000000ff980000,0x00000000ffb634f8,0x00000000ffb80000)
  from space 512K, 0% used [0x00000000ffc00000,0x00000000ffc00000,0x00000000ffc80000)
  to   space 512K, 0% used [0x00000000ffb80000,0x00000000ffb80000,0x00000000ffc00000)
  //老年代
 ParOldGen       total 7168K, used 0K [0x00000000fec00000, 0x00000000ff300000, 0x00000000ff980000)
  object space 7168K, 0% used [0x00000000fec00000,0x00000000fec00000,0x00000000ff300000)
  //元空间
 Metaspace       used 3402K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 380K, capacity 388K, committed 512K, reserved 1048576K

堆空间大小设置

  • -Xms用于表示堆空间的起始内存
  • -Xmx用于表示堆空间的最大内存

一旦堆空间内存大小(新生代+老年代)超过-Xmx所指定的最大内存时,会抛出OOM异常。通常将-Xms-Xmx两个参数配置相同的值,其目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分割计算堆区的大小,从而提高性能

默认情况下,初始内存大小为物理内存大小/64,最大内存大小为物理内存大小/4。

    //堆初始内存总量
    long initalMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
    //最大堆内存
    long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
    System.out.println("initial:" + initalMemory);//184
    System.out.println("maxMemory:" + maxMemory);//2706

    System.out.println("系统内存大小" + initalMemory * 64.0 / 1024 + "G");//11.5
    System.out.println("系统内存大小" + maxMemory * 4.0 / 1024 + "G");//10.5703125

现在设置堆内存大小-Xms10m ,并且-XX:+PrintGCDetails打印细节

initial:9
maxMemory:9
系统内存大小0.5625G
系统内存大小0.03515625G
[GC (Allocation Failure) [PSYoungGen: 2048K->488K(2560K)] 2048K->744K(9728K), 0.0007722 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 2560K, used 529K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 2% used [0x00000000ffd00000,0x00000000ffd0a560,0x00000000fff00000)
  from space 512K, 95% used [0x00000000fff00000,0x00000000fff7a020,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
 ParOldGen       total 7168K, used 256K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 3% used [0x00000000ff600000,0x00000000ff640000,0x00000000ffd00000)
 Metaspace       used 3517K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 389K, capacity 392K, committed 512K, reserved 1048576K

为什么设置了10m,结果却显示9m呢?因为在计算初始大小的时候,S0区或者S1区只选择一个使用,即结果是2048K+512K+7168K=9728K=9M

OOM示例:

public class Demo {
    public static void main(String[] args) {
        List<Test2> list=new ArrayList<>();
        while (true) {
            list.add(new Test2());
        }
    }
}

class Test2{

}
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

新生代与老年代

  • 存储在JVM中的Java对象可以被划分为两类:
    • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
    • 另外一类的对象的生命周期非常长,在某些极端情况下还能够与JVM的生命周期保持一致
  • Java堆可分为新生代(YoungGen)和老年代(OldGen)
  • 其中年轻代又可以划分为Eden、Survivor0、Survivor1
  • 配置新生代和老年代在堆中占比-XX:NewRatio=2,表示新生代:老年代=1:2(默认情况下为2,一般不需要改)
  • -XX:SurvivorRatio调整Eden区与S0、S1的比例,默认为8,意思为8:1:1
  • 几乎所有的Java对象都是在Eden区new出来的(当Java对象太大,Eden放不下的时候不会在Eden中new)
  • 绝大部分的Java对象销毁都在年轻代中进行
  • 可以使用-Xmn设置新生代最大内存大小(一般不需要改)
  • -XX:-UseAdaptiveSizePolicy 自适应的内存分配策略,默认情况下开启

对象分配过程

  1. new的对象首先放到伊甸园区。注意如果放不下直接去老年代。
  2. 当伊甸园区的空间填满时,程序又需要创建对象时,JVM的垃圾回收期将伊甸园区进行YGC(young/Minor GC,只有当伊甸园区满的时候才会触发),将伊甸园区不再被其他对象所引用的对象销毁。再加载新的对象放到伊甸园区。
  3. 将伊甸园区经步骤2 GC后幸存(剩余)的对象移动到S0区
  4. 如果再次触发GC,把伊甸园区幸存对象、S0区的剩余对象放到S1区
  5. 如果再次GC,此时会重新放回S0区,接着再去S1区,如此反复倒腾(S0、S1一定有一个区是空着的)
  6. 每次经历GC后年龄计数器次数加1,当到达15次时就会去老年代。(可以设置-XX:MaxTenuringThreshold=<N>进行设置该次数,默认是15)(为什么要分代?提高GC性能,因为大部分对象都是朝生夕灭)
  7. 当老年代内存不足时,再次触发major/Full GC,进行老年代的内存清理
  8. 若老年代执行major GC之后依然无法进行对象保存,就会产生OOM异常。
  • S0、S1区总结:复制之后有交换,谁空谁是TO
  • 关于GC:频繁在新生代收集,很少在老年代收集,几乎不在永久区/元空间收集

Minor GC、Major GC、Full GC

JVM在进行GC时,并非每次都对上面三个内存区域一起回收,大部分时候回收的都是指新生代。

Hotspot里把GC分为部分收集(Partial GC)与整堆收集(Full GC)

  • 部分收集(Partial GC):不是完整收集整个Java堆的垃圾收集。其中又分为:
    • 新生代收集(minor GC/ young GC):只是新生代的垃圾收集
    • 老年代收集(major GC/ old GC):只是老年代的垃圾收集
      • 目前只有CMS GC会有单独收集老年代的行为
      • 很多时候major GC会与full GC混淆使用,需要具体分辨是老年代回收还是整堆回收
    • 混合收集(mixed GC):收集整个新生代以及部分老年代的垃圾收集
      • 目前只有G1 GC会有这种行为
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集

年轻代GC触发机制

  • 当年轻代空间不足时,就会触发minor GC,这里指的是伊甸园区满,survivor区满不会触发GC。

  • Java对象大多都具备朝生夕灭的特性,所以minor GC非常频繁,一般回收速度也比较快

  • minor GC会引发STW(stop the world),暂停其他用户线程,等垃圾回收结束,用户线程才恢复运行

老年代GC触发机制

  • 指发生在老年代的GC,major GC或者full GC
  • 出现了major GC,经常伴随至少一次的minor GC(但也不是绝对的,parallel scavenge收集器的收集策略里有直接进行major GC的策略选择过程)
  • major GC的速度一般会比minor GC慢10倍以上,STW时间更长
  • 如果major GC后内存还不足,就报OOM

Full GC触发机制

  1. 调用System.gc(),系统建议执行full GC,但是不一定
  2. 老年代空间不足
  3. 方法区空间不足
  4. minor GC后进入老年代的平均大小大于老年代的可用内存
  5. 由伊甸园区、from区向to区复制时,对象大小大于to区可用内存,则把该对象转存到老年代,且老年代可用内存小于该对象大小

full GC是开发或调优中尽量要避免的,因为STW时间比较长

内存分配策略(对象提升(Promotion)规则)

针对不同年龄段的对象分配原则如下所示:

  • 优先分配到伊甸园区
  • 大对象直接分配到老年代(尽量避免程序中出现过多大对象)
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断
    • 如果survivor区中相同年龄的所有对象大小的总和大于survivor空间的一半,年龄大于等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄
  • 空间分配担保:-XX:HandlePromotionFailure

TLAB(Thread Local Allocation Buffer)

为什么会有TLAB?

堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据,因此在并发环境下从堆区中划分内存空间是线程不安全的,如果使用加锁机制会影响分配速度

什么是TLAB?

  • 从内存模型的角度对伊甸园区继续进行划分,JVM为每个线程分配了一个私有缓存区域,多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,这种内存分配方式称为快速分配策略
  • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实将TLAB作为内存分配的首选
  • 通过-XX:UseTLAB设置是否开启TLAB(默认开启)
  • 通过-XX:TLABWasteTargetPercent设置TLAB所占伊甸园区空间百分比(默认1%)
  • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在伊甸园区中分配内存。

堆空间常用参数总结

  • -XX:+PrintFlagsInitial查看所有参数的默认值
  • -XX:+printFlagsFinal查看所有参数的最终值
  • -Xms:初始堆空间内存(默认为物理内存的1/64)
  • -Xmx:最大堆空间内存(默认为物理内存的1/4)
  • -Xmn:新生代大小
  • -XX:NewRation:老年代:新生代
  • -XX:SurvivorRatio:伊甸园区与S0/S1比例
  • -XX:MaxTenuringThreshold:新生代最大年龄
  • -XX:+PrintGCDetails:输出详细GC处理日志
  • -XX:+PrintGC-verbose:gc打印简要GC信息
  • -XX:HandlePromotionFailure是否设置空间分配担保。在发生YGC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果大于,此次YGC是安全的。如果小于,则虚拟机会查看该值是否允许担保失败,如果为true,则会检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行YGC,如果小于,则进行Full GC。如果值为false,则进行一次full GC。在JDK6之后,该参数默认为true且代码中也不再使用。也就是说JDK6之后只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行YGC,否则进行Full GC。

逃逸分析

随着JIT编译期的发展与逃逸分析技术主键成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

在Java虚拟机中,对象是Java堆中分配内存的。但是有一种特殊情况,如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无需进行GC了,这也是最常见的堆外存储技术。-XX:-DoEscapeAnalysis关闭逃逸分析(默认开启)

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。

举例:

//没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除
public void myMethod(){
    Test t=new Test();
    //do something
    t=null;
}
//快速判断是否逃逸,就看new的对象是否有可能在方法外被调用
public class EscapeAnalysis{
    public EscapeAnalysis obj;
    //方法返回EscapeAnalysis对象,发生逃逸
    public EscapeAnalysis getInstance(){
        return obj ==null?new EscapeAnalysis(): obj;
    }
    //为成员属性赋值,发生逃逸
    public void setObj(){
        this.obj=new EscapeAnalysis();
    }
    //对象的作用域仅在当前方法中有效,没有发生逃逸
    public void useEscapeAnalysis(){
        EscapeAnalysis e=new EscapeAnalysis();
    }
    //引用成员变量的值,发生逃逸
    public void useEscapeAnalysis1(){
        EscapeAnalysis e= getInstance();
    }
}

开发中能使用局部变量的,就不要在方法外定义!

逃逸分析:代码优化

使用逃逸分析,编译器可以对代码做如下优化(然而由于逃逸技术不够成熟,目前Hotspot虚拟机只使用了标量替换优化,所以对象实例都分配到堆上):

  1. 栈上分配。将堆分配转化为栈分配。
  2. 同步省略。如果一个对象被发现只能从一个线程访问到,那么对这个对象的操作可以考虑不同步。在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,JIT编译器在编译的时候会取消这部分代码的同步,大大提高了并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除
  3. 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。标量是指一个无法再分解成更小的数据的数据,Java中的原始数据类型就是标量。相对的,那些还可以分解的数据叫做聚合量,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问,那么经过JIT优化,就会把这个对象拆解为若干个成员变量代替,这个过程就是标量替换。-XX:+EliminateAllocations开启标量替换(默认开启)
clsss Point{
    private int x;
    private int y;
}
public static void main(String[] args){
    alloc();
}
private static void alloc(){
    //因为point不被外界调用,所以可以拆分为int x=1 int y=2,然后存储在虚拟机栈中,大大减少了堆内存的占用
    Point p=new Point(1,2);
}

程序计数器(PC寄存器)

Program Counter Register,类似于CPU中的PC寄存器。用来存储指向下一条指令的地址。由执行引擎读取下一条指令。

  • 它是一块很小的内存空间,也是运行速度最快的存储区域。

  • 每个线程都有自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。

  • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是undefined。

  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
  • 它是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError(OOM)情况的区域,也没有GC

虚拟机栈

栈是运行时的单位,而堆是存储的单位。即栈解决程序的运行问题,堆解决数据存储问题。

每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个栈帧(Stack Frame),对于着一次次的Java方法调用。虚拟机栈是线程私有的。生命周期与线程一致。主管Java程序的运行,它保存方法的局部变量(8种基本数据类型boolean,byte,char,short,int,float,long,double,对象的引用地址)、部分结果,并参与方法的调用和返回。

在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧是有效的,这个栈帧被称为当前栈帧,与当前栈帧相对应的方法就是当前方法,定义这个方法的类就是当前类。

执行引擎运行的所有字节码指令只针对当前栈帧进行操作。

如果在该方法中调用了其他方法,对于的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。

不同线程中所包含的栈帧是不允许存在相互引用的,即不可能再一个栈帧之中引用另外一个线程的栈帧。

如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令另外一种是抛出异常,不管使用哪种方式,都会导致栈帧被弹出

优点

  • 栈的访问速度仅次于程序计数器
  • JVM对虚拟机栈的操作只有两个,每个方法执行,进栈;执行结束,出栈
  • 对于栈来说不存在垃圾回收问题(不存在GC)

栈帧的内部结构

QQ截图20210423110539.jpg

局部变量表(Local Variables)

  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型(byte、short、char、int、long、boolean、double、float)、对象引用,以及returnAddress类型。
  • 由于局部变量表是建立在线程的栈上的,是线程私有的,不存在数据安全问题
  • 局部变量表的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。
  • 局部变量表的存储单元是slot,32位类型占用一个slot,64位(long、double)占用两个slot。
  • 方法嵌套调用的次数由栈的大小决定。栈越大,方法嵌套调用次数越多。
  • 局部变量表中的变量只在当前方法调用中有效。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
  • JVM会为局部变量表中的每一个slot分配一个访问索引,通过这个索引可以成功访问到局部变量表中指定的局部变量值。
  • 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上
  • 如果需要访问局部变量表中一个64bit的局部变量值是,只需要使用前一个索引即可(long、double)
  • 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处(这也是为什么静态方法无法用this的原因,因为不存在this),其余的参数按照参数表顺序继续排列
  • 在栈帧中,与性能调优关系最为密切的部分就是局部变量表(局部变量表可能会有对堆的引用,如果引用不存在了,需要及时回收堆中的垃圾)
  • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收

在一个类里面写如下代码,使用jclasslib分析字节码

public static void test() {
    int a = 2;
    double b = 2L;
    Date date = new Date();
    System.out.println(a);
}
 0 iconst_2
 1 istore_0
 2 ldc2_w #2 <2.0>	
 5 dstore_1
 6 new #4 <java/util/Date>	
 9 dup
10 invokespecial #5 <java/util/Date.<init>>
13 astore_3
14 getstatic #6 <java/lang/System.out>
17 iload_0
18 invokevirtual #7 <java/io/PrintStream.println>
21 return

image.png

这是该方法的局部变量表,可以看到int和date占了一个slot,double占了2个slot。起始PC表示字节码指令行号,长度表示作用域(比如从2开始,作用20行。也就是到22行结束)

slot重复利用

栈帧中的局部变量表的slot是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。举例:

public void test1() {
    int a = 1;
    {
        int b = 2;
        b += a;
    }
    int c = a + 2;
}

QQ截图20210422153039.jpg

可以看到变量c占用了b的位置,总共是用了3个slot。并且由于不是静态方法,所以this从0开始

操作数栈(Operand Stack)

  • 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
  • 在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据
  • 如果被调用的方法带有返回值,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令
  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段再次验证
  • Java虚拟机的解释引擎是基于栈的执行引擎,其中栈指的就是操作数栈
  • 每个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的code属性中,max_stack
  • 32bit占用一个栈单位,64bit占用两个栈单位
  • 操作数栈是采用数组实现的栈,但是无法采用访问索引的方式来进行数据访问

示例:

public void test3() {
    byte a = 1;
    int b = 2;
    int c = a + b;
}

byte、short、char、boolean都以int型来保存

0 iconst_1	//int 变量1
1 istore_1	//把int变量1存到局部变量表1 slot中
2 iconst_2	//int 变量2
3 istore_2	//把int 变量2存到局部变量表2 slot中
4 iload_1	//从局部变量表1 slot中取数据
5 iload_2	//从局部变量表2 slot中取数据
6 iadd		//完成加法操作
7 istore_3	//把结果存到slot 3
8 return

栈顶缓存技术

由于操作数是存储在内存中的,因此频繁地执行内存读写操作必然会影响执行速度。栈顶缓存技术(Top-of-Stack Cashing)就是将栈顶元素全部缓存在物理CPU寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率。

动态链接(Dynamic Linking)

  • 指向运行时常量池的方法引用
  • 每个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接
  • 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转化为调用方法的直接引用

补充:方法的调用

在JVM中,将符号引用转换为调用方法的直接引用和方法的绑定机制相关。

  • 静态链接:当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译器可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
  • 动态链接:如果被调用的方法在编译期无法被确定下来,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接

相应的绑定机制分为早期绑定和晚期绑定。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,仅仅发生一次。

  • 早期绑定:如果被调用的目标方法在编译器可知,且运行期保持不变时,即可将这个方法和所属的类型进行绑定
  • 晚期绑定:如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式被称之为晚期绑定。(接口)
虚方法与非虚方法
  • 如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这样的方法称为非虚方法
  • 静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法
  • 其他方法称为虚方法

虚拟机中提供了以下几种方法调用指令:

  • 普通调用指令:
    1. invokestatic:调用静态方法,解析阶段确定唯一方法版本
    2. invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
    3. invokevirtual:调用所有虚方法
    4. invokeinterface:调用接口方法
  • 动态调用指令:5. invokedynamic:动态解析出需要调用的方法,然后执行(Lambda表达式)

    Java本身是静态类型语言,静态类型语言是判断变量自身的类型信息,如String s=”aa”; s=2;就会在编译的时候报错

    而相应的JS则是动态类型语言,变量没有类型信息,var s=2; var s=”asd”

前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持用户确定方法版本。其中invokestatic和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法


Java语言中方法重写的本质:

  1. 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C。
  2. 如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常
  3. 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程
  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常

IllegalAccessError:

程序试图访问或修改一个属性或调用一个方法,并且你没有权限访问,会引起编译器异常


  • 在面向对象的编程中,会很频繁的使用到动态分派,如果每次动态分派都要重新在类的方法元数据中搜索合适的目标的话就可能会影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。
  • 每个类中都有一个虚方法表,表中存放着各个方法的实际入口
  • 虚方法表会在类加载的链接阶段被创建并初始化,类的变量初始值准备完成后,JVM会把该类的方法表也初始化完毕

方法返回地址(Return Address)

存放调用该方法的PC寄存器的值。

正常完成出口和异常完成出口的区别在于:通过异常完成出口的退出不会给他的上层调用者产生任何返回值。

当一个方法开始执行后,只有两种方式可以退出这个方法:

  1. 正常完成出口。执行引擎遇到任意一个方法返回的字节码指令,会有返回值传递给上层的方法调用者。(字节码指令包括ireturn、lreturn、freturn、dreturn、areturn、return)
  2. 异常完成出口。在方法执行的过程中遇到了异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。

一些附加信息

本地方法栈

Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法(由C语言实现的一些方法)的调用。

当某个线程调用本地方法的时候,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。

  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
  • 它甚至可以直接使用本地处理器中的寄存器
  • 直接从本地内存的堆中分配任意数量的内存

面试题

  1. 说一下JVM内存模型吧,都有哪些区?分别干什么的?
  2. JVM栈和堆的区别?
  3. 对象的访问定位的两种方式
  4. 为什么两个survivor区?
  5. Eden和survivor的比例分配?
  6. 为什么要有新生代和老年代?
  7. 什么时候对象会进入老年代?
  8. JVM的内存模型,Java8做了什么修改?
  9. 永久代会发生垃圾回收吗?
  10. 为什么要使用PC寄存器?PC寄存器存储字节码指令地址有什么用?答:因为CPU在不停的切换各个线程,切换回来之后需要知道接着从哪里继续执行。JVM字节码解释器需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
  11. PC寄存器为什么会被设定为线程私有?因为多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换。为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法是为每一个线程都分配一个PC寄存器,保证线程之间可以独立计算,从而不会出现相互干扰。
  12. 开发中遇到的异常有哪些?

    Java虚拟机规范允许的Java栈的大小是动态的或者是固定不变的

    如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常。(递归没有出口的时候就会报这个错)

    如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutOfMemoryError(OOM)异常。

    设置栈内存大小,参数-Xss来设置线程的最大栈空间。

  13. 举例栈溢出的情况

    StackOverflowError

  14. 调整栈大小, 就能保证不出现溢出吗?

    不能。如果递归没有终止条件,不管怎么调整栈大小,都会溢出。只能让出现时间变慢。

  15. 分配的栈内存越大越好吗?

    不是。单个栈越大,整个线程就被挤占了,并发线程数量就少了。

  16. 垃圾回收是否会涉及到虚拟机栈

    不会。虚拟机栈通过出栈实现回收,不需要垃圾回收算法。

  17. 方法中定义的局部变量是否线程安全?

    具体问题具体分析。

public class StringBuilderTest {

    //线程安全
    public static void method1() {
        StringBuilder s1 = new StringBuilder();
        s1.append("a");
        s1.append("b");
    }

    //线程不安全。Stringbuilder可能会被多个线程调用
    public static void method2(StringBuilder stringBuilder) {
        stringBuilder.append("a");
        stringBuilder.append("b");
        System.out.println("method2:" + stringBuilder.toString());
    }

    //线程不安全
    public static StringBuilder method3() {
        StringBuilder s1 = new StringBuilder();
        s1.append("a");
        s1.append("b");
        return s1;
    }

    //线程安全
    public static String method4() {
        StringBuilder s1 = new StringBuilder();
        s1.append("a");
        s1.append("b");
        return s1.toString();
    }

    public static void main(String[] args) {
        StringBuilder s = new StringBuilder();
        
        new Thread(() -> {
            s.append("b");
            s.append("a");
        }).start();
        
        method2(s);
    }
}

Tags:

Categories:

Updated: