java自动内存管理机制

 

 

  最近通过看《深入理解JVM虚拟机》学习Java VM,学完第二章java自动内存管理机制,作了一些读书笔记。

 

  Java虚拟机在执行Java程序的过程中会把它所管理的区域分为若干个不同的数据区域。这些数据区域都有各自的用途,以及创建和销毁的时间,具体如下图所示:

程序计数器:
  程序计数器是一块较小的空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器就是通过改变这个计数器的值来指示下一条需要执行的字节码的指令。
为了线程切换后能够恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行一个java方法,那么程序计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值为空。
此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

Java虚拟机栈
  Java虚拟机栈是线程私有的,生命周期和线程相同。
  虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈祯(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至执行完成的过程,就对应一个栈帧在虚拟机栈中入栈和出栈的过程。
  局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
  Java虚拟机栈规定了两种异常情况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

本地方法栈
  本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
  与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

Java堆
  Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
  Java堆是java虚拟机所管理的内存中最大的一块。
  Java堆唯一目的就是存放对象实例。
  java堆是垃圾收集器管理的主要区域,很多时候也被称做为“GC堆”。由于现在收集器基本都采用分代收集算法,所以Java堆中还细分为:新生代和老年代;
  根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照括扩展来实现的。如果堆中没有内存来完成分配,并且堆也无法再扩展时,将会抛OutOfMemoryError异常。

方法区
  方法区是各个线程共享的内存区域。
  方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。在HotSpot也被称为“永久代”(原因是HotSpot把GC分代收集扩展至方法区,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存,JDK1.7放弃此做法,并把字符量常量池移出。)
  当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

常量池
  java中的常量池分为两种状态:静态常量池运行时常量池
  Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用。
  所谓静态常量池,即*.class文件中的常量池,class文件常量池主要用于存放两大类常量:字面量(Literal)和符号引用量(Symbolic References),而运行时常量池,则是jvm虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池。运行时常量池是方法区的一部分。


  问:何为字面量和符号引用?
  答:在java中,一个java类将会编译成一个class文件。在编译时,java类并不知道引用类的实际内存地址,只能使用符号引用来代替。比如org.simple.People类引用org.simple.Tool类,在编译时People类并不知道Tool类的实际内存地址,因此只能通过使用符号org.simple.Tool(假设)来表示Tool的地址。而在类装载器装载People类时,此时可以通过虚拟机获取Tool类的实际内存地址,因此便可以将符号org.simple.Tool替换为Tool类的实际内存地址,即直接引用地址。
  符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:
  • 类和接口的全限定名
  • 字段名称和描述符
  • 方法名称和描述符
  字面量是指由字母,数字等构成的字符串或者数值,它只能作为右值出现,所谓右值是指等号右边的值,如:int a=123这里的a为左值,123为右值。
  例:
    int a;//a变量
    const int b=10;//b为常量,10为字面量
    String str="hello world";//str为变量,hello world为也字面量

  一般来说除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。
  运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定在编译器产生,也就是并非预置入Class文件中的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中。
  当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

  常量池的好处:
    常量池是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。
    例如字符串常量池,在编译阶段就把所有的字符串文字放到一个常量池中。
    (1)节省内存空间:常量池中所有相同的字符串常量被合并,只占用一个空间
  (2)节省运行时间:比较字符串时,==比equals()快。对于两个引用变量,只用==判断引用是否相同,也就可以判断实际值是否相等。
  例:
    String s1 = "Hello";

     String s2 = "Hello";

    String s3 = "Hel" + "lo";

    String s4 = "Hel" + new String("lo");

    String s5 = new String("Hello");

    String s6 = s5.intern();

    String s7 = "H";

    String s8 = "ello";

    String s9 = s7 + s8;

    System.out.println(s1 == s2); // true

    System.out.println(s1 == s3); // true

    System.out.println(s1 == s4); // false

    System.out.println(s1 == s9); // false

    System.out.println(s4 == s5); // false

    System.out.println(s1 == s6); // true

  (1)s1==s2 s1、s2在赋值时,均使用字符串字面量,即在编译期间,“Hello”会直接放入class文件的常量池中,实现复用,载入运行时处理池后,s1、s2指向的是同一个内存地址,所有相等。
  (2)s1==s3 在编译期间,“Hel”+“lo"会被优化,编译器会直接帮你拼接好,因此String s3 = "Hel" + "lo";在class文件中被优化成String s3 = "Hello",所以s1 == s3成立。只有使用引号包含文本的方式创建的String对象之间使用“+”连接产生的新对象才会被加入字符串池中。
  (3)s1 ==s4不成立 s4虽然是拼接出来的,但new String("lo")这部分不是已知字面量,是不可预知的部分,编译器不会优化。对于所有包含new方式新建对象(包括null)的“+”连接表达式,它所产生的新对象都不会被加入字符串池中。

 

  (4)s1 == s9也不相等,道理差不多,虽然s7、s8在赋值的时候使用的字符串字面量,但是拼接成s9的时候,s7、s8作为两个变量,都是不可预料的,编译器毕竟是编译器,不可能当解释器用,不能在编译期被确定,所以不做优化,只能等到运行时,在堆中创建s7、s8拼接成的新字符串,在堆中地址不确定,不可能与方法区常量池中的s1地址相同。

  (5)s1 == s6这两个相等完全归功于intern方法,s5在堆中,内容为Hello ,intern方法会尝试将Hello字符串添加到常量池中,并返回其在常量池中的地址,因为常量池中已经有了Hello字符串,所以intern方法直接返回地址;而s1在编译期就已经指向常量池了,因此s1和s6指向同一地址,相等。

对象的创建
在虚拟机中,对象的创建是怎样一个过程呢?

  1.   虚拟机遇到一条new指令时,首先检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就执行类的加载过程。
  2.   为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配内存空间的任务等同于把一块确定大小的内存从java堆中划分出来。划分方式: 
      •     指针碰撞(内存是规整的)
      •     空闲列表(内存不规整)
  3.   将分配到的内存空间都初始化为零值(不包含对象头)
  4.   对对象进行必要的设置,例如这个对象的哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象头中。
  5.   执行<init>方法,把对象安装程序员的意愿进行初始化。

  执行完上述5步后,一个真正可用的对象才算完全产生。

对象的内存布局
  对象在堆内存中存储的布局可以分为3块区域:对象头、实例数据和对齐填充。
  对象头包含两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,官方称这部分数据为:“Mark Word”。另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  实例数据部分是对象真正存储的有效信息。
  第三部分对齐填充并不是必然存在的,也没有特别的意义,它仅仅起着占位符的作用(HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,对象的大小必须是8字节的整数倍,对象实例数据部分没有对齐时,需要通过对齐填充来补全)。

对象的内存图

1 class Phone{
2     String brand;
3     int price;
4     String color;
5     
6     public void call(String phoneNum){
7         ...
8     }
9 }

 

 1 class Client{
 2     public static void main(String[] args){
 3          Phone p = new Phone();
 4         
 5          System.out.println(p.brand);
 6          System.out.println(p.price);
 7          System.out.println(p.color);
 8         
 9          p.brand = "华为P20";
10         p.price = 3799;
11         p.color = "black"
12         
13         System.out.println(p.brand);
14         System.out.println(p.price);
15         System.out.println(p.color);
16         
17         p.call("12345678911");
18     }
19 }

posted on 2019-05-24 22:46  pufeng  阅读(735)  评论(0编辑  收藏  举报

导航