美文网首页
JVM(五)值传递还是引用传递?

JVM(五)值传递还是引用传递?

作者: hadoop_a9bb | 来源:发表于2020-07-21 18:56 被阅读0次

    1.对象的创建

    1.遇到new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,执行相应的类加载。
    2.类加载检查通过之后,为新对象分配内存(内存大小在类加载完成后便可确认)。在堆的空闲内存中划分一块区域(指针碰撞或空闲列表的分配方式)

    指针碰撞:Java堆是规整的,所有用过的内存放在一边,空闲的放在另一边,中间放着一个指针作为分界点的指示器。分配内存只是把指针向空闲空间挪动与对象大小相等的距离。这种分配称为“指针碰撞”。

    空闲列表:Java堆是不规整的,用过的内存和空闲的内存相互交错。那就没办法进行指针碰撞。虚拟机通过维护一个列表,记录那些内存块是可用的,在分配时找出一块足够大的空间分配给对象实例,并更新表上记录。这种分配方式称为“空闲列表”。

    使用哪种分配方式由Java堆是否规整决定,Java堆规整不规整由垃圾收集器是否算法或者是否带有压缩整理功能决定。

    分配对象保证线程安全的做法:虚拟机使用CAS失败重试的机制保证更新操作的原子性。

    3.每个线程在堆中都会有私有的分配缓冲区(TLAB),这样可以很大程度避免在并发情况下频繁创建 对象造成的线程不安全。
    4.内存空间分配完成后,会初始化0(不包括对象头)
    5.接下来就是填充对象头,把对象是哪个类的实例、如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等信息存入对象头中。
    6.执行<init>方法,把对象按程序员的意愿进行初始化。执行完init方法后才算一份真正可用的对象创建完成。

    2.对象内存布局

    java对象:对象头+实例数据+对齐填充
    对象头:MarkWord (8字节)+ 类指针(8字节 开启压缩4字节) + 数组对象长度数据(4字节)
    实例数据:基础类型占实际大小,引用类型8字节(开启压缩4字节)
    对齐填充:填充到8的倍数。


    3.对象的访问定位

    一般来说,一个Java的引用访问涉及到3个内存区域,JVM栈、堆、方法区

    以最简单的本地变量引用为例
    Object obj = new Object();
    Object obj表示一个本地引用,存储在JVM栈的本地变量表中,表示一个reference类型数据。
    new Object() 作为实例对象数据存储在堆中。
    堆中还记录了能够查询到此Object对象的类型数据(接口、方法、field、对象类型等)的地址,实际的数据则存储在方法区中。
    

    在Java虚拟机规范中,只规定了指向对象的引用,对于reference类型引用访问具体对象的方式并未做规定。不过目前主流的有两种:通过句柄饭顾问,使用直接指针访问。

      1. 通过句柄访问

    通过句柄访问的实现方式中,JVM堆中会划分单独一块内存区域作为句柄池,句柄池中存储了对象实例数据(在堆中)和对象类型数据(在方法区中)的指针,这种实现方式由于用句柄表示地址,因此十分稳定。Java堆中会分配一块内存作为句柄池。reference存储的是句柄地址。详情见图。


    通过句柄访问对象
    • 2 使用直接指针访问
      通过直接指针访问的实现方式中,reference中存储的就是对象在堆中的实际地址,在堆中存储的对象信息包含了在方法区中相应类型数据。这种方法最大的优势就是速度快,HotSpot虚拟机中使用的就是这种方式。


      通过直接指针访问对象

    比较
    使用句柄的最大好处是reference中存储的是稳定的句柄地址,在对象移动(GC)是只改变实例数据指针地址,reference不需要修改。
    直接指针好处就是速度快,节省了一次指针定位的开销。
    如果是对象频繁GC那么句柄访问好,如果是对象频繁访问那么是直接指针访问好。


    4.方法执行过程

    public class Test {
        public static void main (String args[]) {
            Student stu = new Student();
            stu.setName("John");
            System.out.println(stu);
        }
    }
    
    1.通过java.exe 运行Test.class,Test.class文件会被ApplassLoader加载器(双亲委派,启动类加载器和扩展类加载器都不会加载它)加载到JVM中,元空间存储着类信息(类名,方法信息,字段信息)
    2.然后JVM找到Test的主函数入口(main),为main函数建立栈帧,开始执行main函数。
    3.main函数的第一条命令是Student stu = new Student();就是让JVM创建一个Student对象,但是这时候方法区中没有Student类信息,所以JVM会马上加载Student类,把Student类的类型信息放到方法区中。
    4.加载完Stundent类之后,Java在堆区中为一个新的Student分配内存,然后调用构造函数初始化Student实例,这个Student实例持有者指向方法区Student类的类型信息的引用。
    5.当使用stu.setName("John")的时候,JVM根据引用找到Student对象持有的引用定位到方法区中Student类的类型信息的方法表,获得setName函数的字节码地址。
    为setName函数创建栈帧,开始运行setName函数。
    

    5.HotSpot的GC算法实现

    1.HotSpot怎么快速找到GC Root?

    HotSpot使用一组称为OopMap的数据结构。引用会记录在这个数据结构上。
    在类加载完成后,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来
    在JIT编译过程中,也会在栈和寄存器中哪些位置是引用。
    这样子,在GC扫描的时候,就可以直接知道哪些是可达对象了。

    2.安全点

    hotspot只在特定的位置生成OopMap,这些位置称为安全点。
    程序执行过程中并非所有的地方都可以停下来开始GC,只有在到达安全点才可以暂停。
    安全点的选定基本上以“是否具有让程序长时间执行”的特征选定的。比如说方法调用,循环跳转,异常跳转等。具有这些功能的指令才会产生Safepoint。

    3.中断方式

    抢占式中断:在GC发生时,首先把所有线程中断,如果发现有线程不在安全点上,就恢复线程,让它跑到安全点上。
    主动式中断:GC需要中断线程时,不直接对线程操作,仅仅设置一个标志,各个线程执行时主动去轮询这个标志,当发现中断标记为true时就自己挂起,轮询标记的地方和安全点是重合的。

    4.安全区域

    一段代码片段中,对象的引用关系不会发生变化,在这个区域中任何地方开始GC都是安全的。
    在线程进入安全区域时,他首先标记自己已经进入啊安全语气,在这段时间里,当JVM发起GC时,就不用管进入安全区域的线程了。
    在线程将要离开安全区域时,他检查系统是否完成了GC过程,如果完成了,就继续前行。否则,他就必须等待收到可以离开安全区域的信号。

    5.GC时为什么要停顿所有Java线程

    因为GC需要先进行可达性分析
    可达性分析是判断GC Root对象到其他对象是否可达
    加入分析过程中对象引用关系在不断变化,分析结果的准确性就无法得到保证。


    6.值传递引用传递

    1.形参与实参
    • 1.形参:方法被调用是需要传递进来的参数,如doJob(int a)中的a,它只有在doJob被调用期间a才有意义,才会被分配内存空间,在方法doJob执行完成后,a就会被销毁释放空间,也就是不存在的。
    • 2.实参:方法被调用时传入的实际值,它在方法被调用之前就已经被初始化并且在方法被调用时传入。

    例如:

    public static void doJob(int a ) {
        a = 20;
        System.out.println(a);
    }
    
    public static void main(String[] args){
        int a = 10; //实参
        doJob(a);
    }
    

    int a = 10;中的a在被调用之前就已经创建并初始化,在调用doJob时,被当做参数传入,所以这个a是实参。
    而doJob(int a)中的a,只有在doJob被调用时,它的生命周期才开始,而在doJob结束之后,他也随之被JVM释放掉,所以这个a是形参。

    2.数据存储策略

    这里要分情况进行探究

    • 1.基本数据类型的存储
      - A.基本数据类型的局部变量
      - B.基本数据类型的全局变量
      - C.基本数据类型的静态变量

    2.引用数据类型的存储
    - A.引用数据类型的局部变量
    - B.引用数据类型的全局变量
    - C.引用数据类型的静态变量

    1.基本数据类型的局部变量

    定义基本数据类型的局部变量以及数据都是直接存储在内存中的栈上(局部变量表中),也就是运行时数据区的虚拟机栈上,数据本身值就存储在局部变量表中。


    虚拟机栈-局部变量表

    在方法内定义的变量直接存储在栈上,如

    int age = 50;
    int weight = 50;
    int grade = 6;
    

    当我们写int age = 50;其实是分为两步的:

    int age;
    age = 50;
    

    首先JVM创建一个名为age的变量,存于局部变量表中,然后去栈中查找是否存在有字面量值为50的内容,如果有就直接把age只想这个地址,如果没有JVM会在栈中开辟一块空间来存储"50"这个内容.并且把age指向这个地址。
    声明并初始化基本数据类型的局部变量时,变量名以及字面量值都是存储在栈中,而且是真实内容

    我们再来看int weight = 50; 按照刚才的思路,字面量为50的内容已经在栈中存在了,因此weight直接指向这个地址。由此可见
    栈中的数据在当前线程下是共享的

    那么如果再执行下面的代码呢?

    weight = 40;
    

    当代码中重新给weight变量进行复制时,JVM会去栈中寻找字面量为40的内容,发现没有,就会开辟一块内存空间存储40这个内容,并且把weight只想这个地址,由此可知:
    基本数据类型的局部变量,本身是不会改变的,当重新赋值时,并不是在内存中改变字面量的内容,而是重新寻找已存在的相同数据,若不存在,则重新开辟内存存新数据。并将引用指向新数据所在的地址


    2.基本数据类型的成员变量

    成员变量:就是在类体重定义的非静态变量


    非静态成员变量存储结构

    我们看per的地址指向的是堆内存中的一块区域,我们来还原一下代码

    public class Person{
      private int age;
      private String name;
      private int grade;
    
      static void run(){
         System.out.println("run...."); 
       };
    }
    
    //调用
    Person per=new Person();
    
    

    成员变量age、name、grade却被存储到了堆中为per对象开辟的一块内存空间。因此可知
    基本数据类型的成员变量名和值都存储于堆中,其生命周期和对象是一致的。


    3.基本数据类型的静态变量

    基本数据类型的静态变量名以及值存储在方法区的运行时常量池中,静态变量随类加载而加载,随类消失而消失。

    4.引用数据类型

    堆是用来存储对象本身和数组,而引用存放的是实际内容的地址值,因此当我们定义一个对象时

    Person per = new Person();
    

    实际上他也是有两个过程

    Person per;
    per = new Person();
    

    在执行Person per时,JVM先在栈中变量表开辟一块内存存放per变量,在执行per= new Person()时,JVM会创建一个Person类的实例对象并在堆中存储这个实例,同时把实例地址赋值给per变量。

    引用数据类型,变量名都在栈中出现,栈中的变量值存储的是对象地址,并不是实际内容。

    5.值传递和引用传递
    • 值传递:在方法被调用时,实参通过形参把它的内容副本传入方法内部,此时形参接收到的内容是实参值的一个拷贝,因此在方法内对形参的任何操作,都仅仅是对这个副本的操作,不影响原始值的内容。
    • 引用传递:引用也就是指向真实内容的地址,在方法调用时,实参的地址通过方法调用被传递给相应的形参,在方法体内,形参和实参指向同一块内存地址,对形参的操作会影响实参的真实内容。

    来看个例子:

    public static void valueCrossTest(int age,float weight){
        System.out.println("传入的age:"+age);
        System.out.println("传入的weight:"+weight);
        age=33;
        weight=89.5f;
        System.out.println("方法内重新赋值后的age:"+age);
        System.out.println("方法内重新赋值后的weight:"+weight);
        }
    
    //测试
    public static void main(String[] args) {
            int a=25;
            float w=77.5f;
            valueCrossTest(a,w);
            System.out.println("方法执行后的age:"+a);
            System.out.println("方法执行后的weight:"+w);
    }
    
    

    输出结果

    传入的age:25
    传入的weight:77.5
    
    方法内重新赋值后的age:33
    方法内重新赋值后的weight:89.5
    
    方法执行后的age:25
    方法执行后的weight:77.5
    

    从上面打印结果可以看到
    a和w作为实参传入valueCrossTest之后,无论在方法内做了什么操作,最终a和w都没变化。

    我们对上面例子进行详细分析:
    首先程序运行时,调用main()方法,此时JVM为main()方法往栈中压入一个栈帧,用来存储main()方法中的局部变量表,操作数栈,方法出口,动态链接等,a和w都存在main()方法局部变量表中。

    main方法局部变量表

    而当执行到ValueCrossTest()方法时,JVM会再压入一个栈帧,用来存放ValueCrossTest()中的局部变量等信息,因此age和weight是在valueCrossTest方法的局部变量表中。而它们的值是从a和w的值copy了一份副本而已。


    valueCrossTest方法局部变量表

    因而a和age,w和weight对应的实际内容是不一致的,所以当在方法内重新赋值时,实际流程为:


    valueCrossTest变量重新赋值

    也就是说,age和weight的改动,只是改变了当前栈帧里的内容,当方法执行结束后,这些局部变量都会被销毁,main方法栈帧重新回到栈顶,称为当前栈帧,再次输出a和w时,依然是初始化时的内容。
    值传递传递的是真实内容的副本,对副本操作不影响原内容,也就是形参怎么变化,不会影响实参的内容

    举个例子:

    public class Person {
            private String name;
            private int age;
    
            public String getName() {
                return name;
            }
            public void setName(String name) {
                this.name = name;
            }
            public int getAge() {
                return age;
            }
            public void setAge(int age) {
                this.age = age;
            }
    }
    

    写个示例测试一下

    public static void PersonCrossTest(Person person){
            System.out.println("传入的person的name:"+person.getName());
            person.setName("我是张小龙");
            System.out.println("方法内重新赋值后的name:"+person.getName());
        }
    //测试
    public static void main(String[] args) {
            Person p=new Person();
            p.setName("我是马化腾");
            p.setAge(45);
            PersonCrossTest(p);
            System.out.println("方法执行后的name:"+p.getName());
    }
    
    

    输出结果:

    传入的person的name:我是马化腾
    方法内重新赋值后的name:我是张小龙
    方法执行后的name:我是张小龙
    

    可以看出,person结果personCrossTest()方法的执行之后,内容发生了改变,看起来是引用传递的。

    下面我们对上面的例子稍作修改,加上一行代码

    public static void PersonCrossTest(Person person){
            System.out.println("传入的person的name:"+person.getName());
            person=new Person();//加多此行代码
            person.setName("我是张小龙");
            System.out.println("方法内重新赋值后的name:"+person.getName());
        }
    
    

    输出结果:

    传入的person的name:我是马化腾
    方法内重新赋值后的name:我是张小龙
    方法执行后的name:我是马化腾
    

    为什么会不一样了呢?
    程序执行到main()方法中下列代码时

    Person p=new Person();
    p.setName("我是马化腾");
    p.setAge(45);
    
    

    是在main方法栈帧局部变量表中,创建一个引用p,p的值是地址,是存储在堆区中p对象的真实地址。


    引用结构

    当执行到PersonCrossTest方法时,因为方法内有这么一行代码:

    person=new Person();
    

    JVM需要在堆内另外开辟一块内存来存储new Person(),加入地址为“xo3333”,那么此时形参指向了这个地址,加入真的是引用传递,那么由上面讲到引用传递中形参实参指向同一对象,形参的操作会改变实参对象,可以退出。实参也应该指向新创建的person对象的地址。然而实际上并不是这样。由此可见引用传递在java中并不存在。

    那么为什么第一个例子中,在方法内修改了形参的内容,会导致原始对象的内容发生改变?
    是因为无论是基本类型还是引用类型,在实参传入形参时,都是值传递,也就是说传递的都是一个副本,而不是内容本身

    修改形参

    由图可以看出,方法实参和形参并无实际关联,只是从p处拷贝了一份指向对象的地址。此时 p和person都指向堆中同一个对象。
    在第二个例子中,当执行到new Person()之后,JVM在堆内开辟一块空间存储新对象,并且把person改成指向新对象的地址,此时 p依旧指向旧的地址,person则指向了新的地址。

    有个例子很形象的说明值传递和引用传递。
    你有一把钥匙,当你的朋友想要去你家的时候,如果你直接把你的钥匙给他了,这就是引用传递。这种情况下,如果他对这把钥匙做了什么事情,比如他在钥匙上刻下了自己名字,那么这把钥匙还给你的时候,你自己的钥匙上也会多出他刻的名字。

    你有一把钥匙,当你的朋友想要去你家的时候,你复制了一把新钥匙给他,自己的还在自己手里,这就是值传递。这种情况下,他对这把钥匙做什么都不会影响你手里的这把钥匙。

    但是,不管上面那种情况,你的朋友拿着你给他的钥匙,进到你的家里,把你家的电视砸了。那你说你会不会受到影响?而我们在方法中,改变对象的属性的值的时候,不就是在“砸电视”么。


    总结

    简单来说:
    如果参数是基本类型,传递的是基本类型的字面量值的拷贝。
    如果参数是引用类型,传递的是该参量所引用的对象在堆中地址值的拷贝。

    相关文章

      网友评论

          本文标题:JVM(五)值传递还是引用传递?

          本文链接:https://www.haomeiwen.com/subject/wxaekktx.html