写在前面
基本问题:
- 介绍下 Java 内存区域
- Java 对象的创建过程
- 对象的访问定位的两种方式(句柄和直接指针两种方式)
拓展问题:
- String类和常量池
- 8种基本类型的包装类和常量池
1.java内存区域
java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁时间,有的区域随着虚拟机进程的启动而存在,有些区域依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范SE7版》的规定,Java虚拟机所管理的内容将会包括以下几个运行时数据区域,如果所示:
线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共享的:
- 堆
- 方法区
- 直接内存
1.1.程序计数器
程序计数器是一块较小的空间,类似于PC寄存器,它可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。Java虚拟机是多线程轮流切换执行的,所以程序计数器是每个线程所独有的,各个线程之间的计数器互不影响,独立存储,我们称这内存区域为“线程私有”的内存。改内存区域是java虚拟机规范中唯一一个没有规定任何OutOfMemoryError情况的区域。
1.2. Java虚拟机栈
java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。在java虚拟机规范中,对这个区域规定了两种异常状况,如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError;如果虚拟机可以动态扩展,如果扩展是无法申请到足够的内存,就会抛出OutOfMemoryError异常。
1.3. 本地方法栈
本地方栈的作用与虚拟机栈发挥的作用是非常相似的,他们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。Native方法常用于两种情况:一是在方法中调用一些不是java语言写的代码,二是在方法中用java语言直接操纵计算机的硬件。
1.4. java堆
java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:在细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
在 JDK 1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域(永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)。
1.5.方法区
方法区与java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。该区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,java语言并不要求常量一定只有编译器才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。
1.6 运行时常量池
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)
既然运行时常量池时方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
JDK1.7及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。
1.7. 直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常。在JDK1.4中新加入了NIO(New
Input/Output)类,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样能在一些场景中显著提高性能,因为避免了在java堆和Navtive堆中来回复制数据。
2.虚拟机对象探秘
Java 是一门面向对象的编程语言,在Java 程序运行过程中无时无刻都有对象被创建出来.在语言层面上,创建对象(例如克隆、反序列化)通常仅仅是一个new 关键字而己,而在虚拟机中,对象(指普通Java 对象,非数组和Class 对象等) 的创建是一个非常复杂的过程。
2.1.对象创建
Java 是一门面向对象的编程语言,在Java 程序运行过程中无时无刻都有对象被创建出来.在语言层面上,创建对象(例如克隆、反序列化)通常仅仅是一个new 关键字而己,而在虚拟机中,对象(指普通Java 对象,非数组和Class 对象等) 的创建是一个非常复杂的过程。
虚拟机遇到一条new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java 堆中划分出来。
假设Java 堆中内存是绝对规整的,就仅仅是把指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”( Bump the Pointer)。
如果Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,虚拟机就必须维护一个列表,记录哪些内存块是可用的, 在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录,这种分配方式称为“空闲列表”( Free List )。
选择哪种分配方式由Java 堆是否规整决定,而Java 堆是否规整是由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew 等带Compact(紧凑)过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS 这种基于Mark-Sweep 算法的收集器时,通常采用空闲列表。
还要考虑内存分配在多线程下同步问题。一种解决办法是对分配内存空间的动作进行同步处理。实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性:另一种是把内存分配的动作按照线程划分在不同的空间之中进行,每个线程在Java 堆中预先分配一小块内存,称为本地线程分配缓冲( Thread Local Allocation Buffer)。哪个线程要分配内存,就在哪个线程的TLAB 上分配,只有TLAB 用完并分配新的于LAB 时,才需要同步锁定
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),接下来,虚拟机要对对象进行必要的设置。例如这个对象是哪个类的实例、如何才能找到类的元数信息、对象的哈希码、对象的GC 分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等。
2.2.对象内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3 块区域:对象头( Header )、实例数据(Instance Data)和对齐填充(Padding)。
Hot Spot 虚拟机的对象头包括两部分信息,
第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID 、偏向时间戳等,这部分数据的长度在32 位和64 位的虚拟机(未开启压缩指针)中分别为32bit 和64bit,官方称它为“Mark Word”。Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。
在32 位的HotSpot 虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的32bit空间中的25bit用于存储对象哈希码, 4 bit 用于存储对象分代年龄, 2 bit 用于存储锁标志位, 1 bit 固定为0,
其他状态(轻量级锁定、重量级锁定、GC 标记、可偏向)下对象的存储内容如下图所示 :
对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java 对象的元数据信息确定Java 对象的大小,但是从数组的元数据中无法确定数组的大小。
数据部分,是对象存储的真正有效信息,也是在程序代码中所定义的各种类型字段的内容。包括父类和接口继承下来的,也包括子类中定义的。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源代码中定义顺序的影响。从分配策略中知道,相同宽度的字段总是被分配到一起。在这个前提下父类定义的变量会出现在子类之前。
对齐填充,不是必须的,只起到地址对齐的作用。HotSpot自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是对象内存大小必须是8字节的整数倍。
2.3.对象访问定位
Java程序通过栈上的reference数据来操作堆上的具体对象,由于reference类型在Java虚拟机规范中只规定了一个指向对象引用。而没有规定这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,它取决于Java虚拟机实现。
目前主要有两种实现方式:
使用句柄(类似间接指针):在Java堆中划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,句柄中包含对象实例数据与类型各自具体地址信息。示例图如下图所示。
直接指针访问:Java堆中的对象布要考虑如何放置访问类型数据相关的信息,而reference中存储的直接就是对象地址。示例图如下图所示。
两者的比较:
-
句柄的好处:reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。
-
直接指针访问的最大好处就是速度更快,节省一次指针定位时间开销,因为对象访问在Java中非常频繁,这类开销积少成多也非常可观。
3. 重点补充内容
String 类和常量池
1 String 对象的两种创建方式:
String str1 = "abcd";
String str2 = new String("abcd");
System.out.println(str1==str2);//false
这两种不同的创建方法是有差别的,第一种方式是在常量池中拿对象,第二种方式是直接在堆内存空间创建一个新的对象。
记住:只要使用new方法,便需要创建新的对象。
2 String 类型的常量池比较特殊。它的主要使用方法有两种:
- 直接使用双引号声明出来的 String 对象会直接存储在常量池中。
- 如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern 方String.intern() 是一个 Native 方法,它的作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,则在常量池中创建与此 String内容相同的字符串,并返回常量池中创建的字符串的引用。
String s1 = new String("计算机");
String s2 = s1.intern();
String s3 = "计算机";
System.out.println(s2);//计算机
System.out.println(s1 == s2);//false,因为一个是堆内存中的String对象一个是常量池中的String对象,
System.out.println(s3 == s2);//true,因为两个都是常量池中的String对
3 String 字符串拼接
String str1 = "str";
String str2 = "ing";
String str3 = "str" + "ing";//常量池中的对象
String str4 = str1 + str2; //在堆上创建的新的对象
String str5 = "string";//常量池中的对象
System.out.println(str3 == str4);//false
System.out.println(str3 == str5);//true
System.out.println(str4 == str5);//false
尽量避免多个字符串拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 StringBuilder 或者 StringBuffer。
问题:String s1 = new String(“abc”);这句话创建了几个对象?
答案:创建了两个对象。
验证:
String s1 = new String("abc");// 堆内存的地值值
String s2 = "abc";
System.out.println(s1 == s2);// 输出false,因为一个是堆内存,一个是常量池的内存,故两者是不同的。
System.out.println(s1.equals(s2));// 输出true
结果:
false
true
解释:
先有字符串"abc"放入常量池,然后 new 了一份字符串"abc"放入Java堆(字符串常量"abc"在编译期就已经确定放入常量池,而 Java 堆上的"abc"是在运行期初始化阶段才确定),然后 Java 栈的 str1 指向Java堆上的"abc"。
八种基本类型的包装类和常量池
- Java
基本类型的包装类的大部分都实现了常量池技术,即Byte,Short,Integer,Long,Character,Boolean;这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。 - 两种浮点数类型的包装类 Float,Double 并没有实现常量池技术。
Integer i1 = 33;
Integer i2 = 33;
System.out.println(i1 == i2);// 输出true
Integer i11 = 333;
Integer i22 = 333;
System.out.println(i11 == i22);// 输出false
Double i3 = 1.2;
Double i4 = 1.2;
System.out.println(i3 == i4);// 输出false
Integer 缓存源代码:
/**
*此方法将始终缓存-128到127(包括端点)范围内的值,并可以缓存此范围之外的其他值。
*/
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
应用场景:
- Integer i1=40;Java 在编译的时候会直接将代码封装成Integer i1=Integer.valueOf(40);,从而使用常量池中的对象。
- Integer i1 = new Integer(40);这种情况下会创建新的对象。
Integer i1 = 40;
Integer i2 = new Integer(40);
System.out.println(i1==i2);//输出false
Integer比较更丰富的一个例子:
Integer i1 = 40;
Integer i2 = 40;
Integer i3 = 0;
Integer i4 = new Integer(40);
Integer i5 = new Integer(40);
Integer i6 = new Integer(0);
System.out.println("i1=i2 " + (i1 == i2));
System.out.println("i1=i2+i3 " + (i1 == i2 + i3));
System.out.println("i1=i4 " + (i1 == i4));
System.out.println("i4=i5 " + (i4 == i5));
System.out.println("i4=i5+i6 " + (i4 == i5 + i6));
System.out.println("40=i5+i6 " + (40 == i5 + i6));
结果:
i1=i2 true
i1=i2+i3 true
i1=i4 false
i4=i5 false
i4=i5+i6 true
40=i5+i6 true
解释:
语句i4 == i5 + i6,因为+这个操作符不适用于Integer对象,首先i5和i6进行自动拆箱操作,进行数值相加,即i4 == 40。然后Integer对象无法与数值进行直接比较,所以i4自动拆箱转为int值40,最终这条语句转为40 == 40进行数值比较。