JVM内存模型

x33g5p2x  于2021-11-21 转载在 Java  
字(10.4k)|赞(0)|评价(0)|浏览(329)

类加载器ClassLoader

负责加载class文件,class文件在文件开头有特定的文件标示,将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定

启动类加载器(Bootstrap)

C++编写的,加载rt.jar
加载java,javax,sun开头的包的类

扩展类加载器(Extension)

Java编写的 加载ext/*.jar

应用程序类加载器(AppClassLoader)

也叫系统类加载器,加载当前应用的classpath的所有类

用户自定义加载器 Java.lang.ClassLoader的子类,用户可以定制类的加载方式

除了启动类加载器,其他的加载器都间接继承ClassLoader这个类

双亲委派

当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。

沙箱安全机制

采用双亲委派的一个好处是比如加载位于 rt.jar 包中的类 java.lang.String,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了源代码的安全。
防止核心API被恶意更改。

Native 本地接口和本地方法栈

本地接口的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++程序,于是就在内存中专门开辟了一块区域处理标记为native的代码,它的具体做法是 Native Method Stack中登记 native方法,在Execution Engine 执行时加载native libraies。

PC寄存器

每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。

这块内存区域很小,它是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令。

如果执行的是一个Native方法,那这个计数器是空的。

为什么使用PC寄存器存储字节码地址?

JVM字节码解释器需要PC寄存器的值来明确下一条要执行的指令是什么

PC寄存器为什么设置为线程私有?

为了记录各个线程正在进行的字节码指令地址,每一个线程都要有一个PC寄存器,如果时公用的,那么多线程,记了线程1为5,线程2为6,就一个怎么记

Stack栈

栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,它的生命期是跟随线程的生命期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题,只要线程一结束该栈就Over,生命周期和线程一致,是线程私有的。8种基本类型的变量+对象的引用变量+实例方法都是在函数的栈内存中分配。

栈可能出现的异常

栈存储什么?

栈中的数据都是以栈帧(Stack Frame)的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法(Method)和运行期数据的数据集

每个方法执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每一个方法从调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。栈的大小和具体JVM的实现有关,通常在256K~756K之间,与等于1Mb左右。

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

具体方法具体分析。
什么是线程安全?
当只有一个线程的操作数据的时候,必定安全。
如果数据是共享的,多个线程操作就会存在安全问题。

Method Area 方法区

供各线程共享的运行时内存区域。它存储了每一个类的结构信息,用于存储虚拟机加载的类型信息,常量,静态变量,即时编译器JIT编译后的代码缓存等。上面讲的是规范,在不同虚拟机里头实现是不一样的,最典型的就是永久代(PermGen space)和元空间(Metaspace)。

对象的实例化内存布局与访问定位

对字节码文件反解析:
javap -v -p XXXX.class

对象创建的方式

1.new:最常见的方式、单例类中调用getInstance的静态类方法,XXXFactory的静态方法

2.Class的newInstance方法:反射的方式,在JDK9里面被标记为过时的方法,因为只能调用空参构造器,并且权限必须为 public

3.Constructor的newInstance(Xxxx):反射的方式,可以调用空参的,或者带参的构造器

4.使用clone():不调用任何的构造器,要求当前的类需要实现Cloneable接口中的clone方法

5.使用序列化:从文件中,从网络中获取一个对象的二进制流,序列化一般用于Socket的网络传输

6.第三方库 Objenesis

对象创建的步骤

1.判断对象对应的类是否加载、链接、初始化

虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池
中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解
析和初始化.

如果该类没有加载,那么在双亲委派模式下,使用当前类加载器以ClassLoader 
+ 包名 + 类名为key进行查找对应的.class文件,如果没有找到文件,则抛出
ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的
Class对象。

2.为对象分配内存

1.首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象
2.如果内存规整:采用指针碰撞分配内存;
3.如果内存不规整: 虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配
的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。
这种分配方式成为了 “空闲列表(Free List)”

3.处理并发问题

采用CAS失败重试保证更新的原子性
每个线程预先分配TLAB - 通过设置 -XX:+UseTLAB参数来设置(区域加锁机制)

4.初始化分配到的空间(属性默认赋值)

所有属性设置默认值

5.设置对象的对象头

将对象的所属类、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对
象头中。这个过程的具体设置方式取决于JVM实现。

6.执行init方法进行初始化(属性显式赋值)

对象的内存布局

对象的访问定位

对象的两种访问方式:句柄访问和直接指针

1.句柄访问

缺点:在堆空间中开辟了一块空间作为句柄池,句柄池本身也会占用空间;通过两次指针访问才能访问到堆中的对象,效率低

优点:reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference本身不需要被修改

2、直接指针(HotSpot采用)

优点:直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据

缺点:对象被移动(垃圾收集时移动对象很普遍)时需要修改 reference 的值

执行引擎

将字节码指令解释/编译为对应平台上的本地机器指令。

javac编译器(前端编译器)流程图如下所示

Java字节码的执行是由JVM执行引擎来完成,流程图如下所示

什么是解释器?什么是JIT编译器?

解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。

JIT(Just In Time Compiler)编译器:就是虚拟机将源代码一次性直接编译成和本地机器平台相关的机器语言,但并不是马上执行。

为什么Java是半编译半解释型语言?

JVM在执行字节码文件的时候,可以一边解释一般立即执行,也可以一次性编译后存到方法区,不立马执行。

通常都会将解释执行与编译执行二者结合起来进行。

JIT编译器将字节码翻译成本地代码后,就可以做一个缓存操作,存储在方法区的JIT 代码缓存中(执行效率更高了),并且在翻译成本地代码的过程中可以做优化。

解释器与JIT编译器

HotSpot VM采用解释器与即时编译器并存的架构

即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。

程序刚启动时,JIT编译器得编译需要时间,先让解释器解释翻译执行着,就相当于热起来,等JIT编译完了,再用JIT编译器

什么时候触发JIT

也可以通过参数设置方法调用的次数,衰减的时间周期等。也可以设置只用解释器或者只用JIT

JIT又分为C1,C2分别是client和server模式下的

AOT编译器

jdk9引入了AoT编译器(静态提前编译器,Ahead of Time Compiler),在程序运行之前,便将字节码转换为机器码的过程。

.java -> .class -> (使用jaotc) -> .so

最大的好处:java虚拟机加载已经预编译成二进制库,可以直接执行。不必等待即时编译器的预热,减少Java应用给人带来“第一次运行慢” 的不良体验

缺点:破坏了 java “ 一次编译,到处运行”,必须为每个不同的硬件,OS编译对应的发行包

Graal 编译器

自JDK10起,HotSpot又加入了一个全新的即时编译器:Graal编译器

编译效果短短几年时间就追平了G2编译器,未来可期(对应还出现了Graal虚拟机,是有可能替代Hotspot的虚拟机的)

字符串常量池(StringTable)

String类的基本特性

1.String:字符串,使用一对 “” 引起来表示

String s1 = "atguigu" ;   			// 字面量的定义方式
  String s2 =  new String("hello");     // new 对象的方式

2.String被声明为final的,不可被继承

3.String实现了Serializable接口:表示字符串是支持序列化的。实现了Comparable接口:表示String可以比较大小

4.String在jdk8及以前内部定义了final char value[]用于存储字符串数据。JDK9时改为byte[]数组外加一个编码标识存储

原因:
字符串是堆使用的主要组成部分,而且大多数字符串对象只包含拉丁字符,这些字
符只需要一个字节的存储空间,因此这些字符串对象的内部char数组(两个字节)中
有一半的空间将不会使用,产生了大量浪费

之前 String 类使用 UTF-16 的 char[] 数组存储,现在改为 byte[] 数组外
加一个编码标识存储。该编码表示如果你的字符是ISO-8859-1或者Latin-1,那
么只需要一个字节存。如果你是其它字符集,比如UTF-8,你仍然用两个字节存

字符串的基本特征

1.不可变性

2.通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中(>=jdk7在堆中)。

字符串常量池的基本特征

1.String Pool(字符串常量池)是一个固定大小的Hashtable,默认值大小长度是1009(jdk6).jdk8中,StringTable的长度默认值是60013,StringTable可以设置的最小值为1009(
-XX:StringTablesize=100009 设置长度)

2.如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.intern()方法时性能会大幅下降,所以数组长度增加了。

字符串拼接操作

1.常量与常量的拼接结果在常量池,原理是编译期优化

2.常量池中不会存在相同内容的变量

3.拼接前后,只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder

4.如果拼接的结果调用intern()方法,根据该字符串是否在常量池中存在,分为:

如果存在,则返回字符串在常量池中的地址
如果字符串常量池中不存在该字符串,则在常量池中创建一份,并返回此对象的地址
@Test
    public void test2(){
        String s1 = "javaEE";
        String s2 = "hadoop";

        String s3 = "javaEEhadoop";
        String s4 = "javaEE" + "hadoop";//编译期优化
        //如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接的结果:javaEEhadoop
        String s5 = s1 + "hadoop";
        String s6 = "javaEE" + s2;
        String s7 = s1 + s2;

        System.out.println(s3 == s4);//true
        System.out.println(s3 == s5);//false
        System.out.println(s3 == s6);//false
        System.out.println(s3 == s7);//false
        System.out.println(s5 == s6);//false
        System.out.println(s5 == s7);//false
        System.out.println(s6 == s7);//false
        //intern():判断字符串常量池中是否存在javaEEhadoop值,如果存在,则返回常量池中javaEEhadoop的地址;
        //如果字符串常量池中不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回次对象的地址。
        String s8 = s6.intern();
        System.out.println(s3 == s8);//true
    }

字符串拼接的底层细节

@Test
    public void test3(){
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        /* 如下的s1 + s2 的执行细节:(变量s是我临时定义的) ① StringBuilder s = new StringBuilder(); ② s.append("a") ③ s.append("b") ④ s.toString() --> 约等于 new String("ab"),但不等价 补充:在jdk5.0之后使用的是StringBuilder,在jdk5.0之前使用的是StringBuffer */
        String s4 = s1 + s2;//
        System.out.println(s3 == s4);//false
    }

对于final修饰的字符串变量就是常量了,所以原理不是StringBuilder而是编译期优化。

/* 1. 字符串拼接操作不一定全部使用的是StringBuilder! 如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式。 2. 针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上。 */
    @Test
    public void test4(){
        final String s1 = "a";
        final String s2 = "b";
        String s3 = "ab";
        String s4 = s1 + s2;
        System.out.println(s3 == s4);//true
    }

拼接操作与 append 操作的效率对比

@Test
    public void test6(){

        long start = System.currentTimeMillis();

// method1(100000);//4014
        method2(100000);//7

        long end = System.currentTimeMillis();

        System.out.println("花费的时间为:" + (end - start));
    }

    public void method1(int highLevel){
        String src = "";
        for(int i = 0;i < highLevel;i++){
            src = src + "a";//每次循环都会创建一个StringBuilder、String
        }
// System.out.println(src);

    }

    public void method2(int highLevel){
        //只需要创建一个StringBuilder
        StringBuilder src = new StringBuilder();
        for (int i = 0; i < highLevel; i++) {
            src.append("a");
        }
// System.out.println(src);
    }

1.通过StringBuilder的append()的方式添加字符串的效率要远高于使用String的字符串拼接方式!

2.原因:

StringBuilder的append()的方式:自始至终中只创建过一个StringBuilder的
对象

String的字符串拼接方式:
	1.每一次拼接都要创建StringBuilder和String(调的toString方法)的对象,内
	存占用更大
	2.如果进行GC,需要花费额外的时间(在拼接的过程中产生的一些中间字符串
	可能永远也用不到,会产生大量垃圾字符串)。

3.StringBuilder的改进空间

StringBuilder底层是一个长度为16的char数组,如果要拼接的字符串过多,可以通过调用有参构造来创建一个指定长度的数组

//highLevel为长度
StringBuilder s = new StringBuilder(highLevel);
//底层相当于new char[highLevel]

intern()方法

public native String intern();

intern是一个native方法,调用的是底层C的方法

intern()方法总结:

1.jdk1.6之前,将字符串尝试放入串池

如果串池中有,则不会放入。返回已有串池的对象的地址
如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址

2.jdk1.7之后,将字符串尝试放入串池

如果串池中有,则不会放入。返回已有串池的对象的地址
如果没有,会把堆中的对象的地址拷贝一份放到串池中(创建一个引用),并返回串
池中引用的地址

两个基础题

new String(“ab”)会创建几个对象?

/** * 题目: * new String("ab")会创建几个对象?看字节码,就知道是两个。 * 一个对象是:new关键字在堆空间创建的 * 另一个对象是:字符串常量池中的对象"ab"。 字节码指令:ldc * */
public class StringNewTest {
    public static void main(String[] args) {
        String str = new String("ab");
    }
}

new String(“a”) + new String(“b”) 会创建几个对象?

/** * 思考: * new String("a") + new String("b")呢? * 对象1:new StringBuilder() * 对象2: new String("a") * 对象3: 常量池中的"a" * 对象4: new String("b") * 对象5: 常量池中的"b" * * 深入剖析: StringBuilder的toString(): * 对象6 :new String("ab") * 强调一下,toString()的调用,在字符串常量池中,没有生成"ab" * */
public class StringNewTest {
    public static void main(String[] args) {

        String str = new String("a") + new String("b");
    }
}

面试题

public class StringTable {
    public static void main(String[] args) {
        String s=new String("1");//创建了俩对象,堆里有一个,常量池中有一个"1"
        s.intern(); //这个方法之前常量池就有"1"了,所以不会在生成一个,还有就是虽然有返回值但是没有赋值给变量s,如果写到上面就是true了
        String s2="1";
        System.out.println(s==s2);//false

        String s3 = new String("1") + new String("1");//pos_1
        s3.intern();
        String s4 = "11";//s4变量记录的地址:使用的是上一行代码代码执行时,在常量池中生成的"11"的地址
        System.out.println(s3 == s4);//jdk6:false jdk7/8:true
    }
}

intern() 的效率测试(空间角度)

/** * 使用intern()测试执行效率:空间使用上 * * 结论:对于程序中大量存在的字符串,尤其其中存在很多重复字符串时,使用intern()可以节省内存空间。 * * */
public class StringIntern2 {
    static final int MAX_COUNT = 1000 * 10000;
    static final String[] arr = new String[MAX_COUNT];

    public static void main(String[] args) {
        Integer[] data = new Integer[]{1,2,3,4,5,6,7,8,9,10};

        long start = System.currentTimeMillis();
        for (int i = 0; i < MAX_COUNT; i++) {
// arr[i] = new String(String.valueOf(data[i % data.length]));
            arr[i] = new String(String.valueOf(data[i % data.length])).intern();

        }
        long end = System.currentTimeMillis();
        System.out.println("花费的时间为:" + (end - start));

        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.gc();
    }
}

1、直接 new String :由于每个 String 对象都是 new 出来的,所以程序需要维护大量存放在堆空间中的 String 实例,程序内存占用也会变高

2、使用 intern() 方法:会使数组中字符串的引用都指向字符串常量池中的字符串,所以程序需要维护的 String 对象更少,内存占用也更低。

结论:

1.对于程序中大量使用存在的字符串时,尤其存在很多已经重复的字符串时,使用intern()方法能够节省很大的内存空间。

2.大的网站平台,需要内存中存储大量的字符串。比如社交网站,很多人都存储:北京市、海淀区等信息。这时候如果字符串都调用intern() 方法,就会很明显降低内存的大小。

G1 中的 String 去重操作

这里的去重是指堆中new的String是相同的
堆上存在重复的String对象必然是一种内存的浪费,G1垃圾收集器中实现自动持续对重复的String对象进行去重

UseStringDeduplication(bool) :开启String去重,默认是不开启的,需要手动开启。

JMM(java内存模型)

JMM是一组规范,定义了程序中各个变量(实例字段,静态字段)的访问方式。

JMM规定了所有的变量都存储在主内存中。每个线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。

JMM是围绕着并发编程中原子性,可见性,有序性这三个特征来建立

原子性(Atomicity)

一个操作不能被打断,要么全部执行完毕,要么不执行。在这点上有点类似于事务
操作,要么全部执行成功,要么回退到执行该操作之前的状态。

可见性

一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量的
这种修改(变化)。

有序性

线程内观察,操作都是有序的,在一个线程中观察另外一个线程,所有的操作都是
无序的。
前半句是指“线程内表现为串行语义”,后半句是指“指令重排”现象和“工作内存和主
内存同步延迟”现象。

volatile

为了保持可见性,就是通知线程b,主内存的变量已经被线程a改了。

volatile可以保证内存可见性,不能保证并发有序性。

synchronized既保证了多线程的并发有序性,又保证了多线程的内存可见性。

相关文章