作者:Snailclimb
基本问题:
- 介绍下 Java 内存区域(运行时数据区)
- Java 对象的创建过程(五步,建议能默写出来并且要知道每一步虚拟机做了什么)
- 对象的访问定位的两种方式(句柄和直接指针两种方式)
拓展问题:
- String类和常量池
- 8种基本类型的包装类和常量池
1 概述
对于 Java 程序员来说,在虚拟机自动内存4 J % }管理机制下,不再需要像C/C++程序开发程序员这样为内一个 ne, V ( = d ? Nw 操作去写对应的 delete/f8 5 n &ree 操作,不容易出现内存泄漏[ j : l P s和内存溢出问题。正是因为 Jat h ~ 2 p Lva 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的I $ 2 D ~ n问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务J C p } r 9。
2 运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域V ? R X @ O ? ^ d。
这些组{ { W成部分一些是线e a w 1 - y P程私有的,其他的则是线程共享的。
线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共享的:
- 堆
- 方法区
- 直接内存
2.W G 1 l _ ~1 程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指W $ v ) L H G示器。字节码解释器工作时通过改变这个计数器的值来选取下一Q 2 M K 4 f条需要执行的字节码指令,分g P ? : } Z E /支、循环、跳转f A e H 6 P ( T、异常处理、线程恢复等功能都需要依赖这个计数器来完。
另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立r U n的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
从上面的介绍中{ U }我们知道程序计数器主要有两个作用:
- 字节码解释器T ! P通过改变程序计数器来依次读取指令u . u u G / b,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
注意:程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
2.2 Java 虚拟机栈
与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命Z X - j S周期和线( # 5程相同,描述的是 Java 方法执行的内存模型。
Jav D w K & M ^ F Ba 内存可以粗糙的区分为堆内存(Heap)和栈内存(Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际x w _ : z @上,Java虚拟机栈是由^ I X L f 8 T一个个栈帧组成,而{ y U ( R r u (每个栈帧中都拥有:局部变量表、操作数栈 K Q 9 J 4 * i、动态链接、方法出口信息。)
局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、ini ^ 0 } E , * + Vt、float、long、double)、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他a I与此对象相关的位置)。
Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。
- StackOverFlowError: 若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverFlow% G g % ? !Error异常。
- OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无[ a : A法再动态扩展了,此时抛出OutOfMemoryError异常。
Java 虚拟机栈也是线程私有的,每个线程都有各自的Java虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。
2.3 本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法被^ 3 ` $ N执行的时候,在本地方法栈也会创建一个栈e @ p U ` j帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会! * $ # M }出现 StackOverI F = x c . !FlowError 和 OutOfMemoryError 两种异常。
2.4 堆
Java 虚拟机所管理的内存中最A V O T o . 2 d大的一块,Jak T { w Eva 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数g w = ?组都在这里分配内存。
Java 堆是U c A Y u ) Q 6垃圾收集器管理的主要区$ X D q _ ] c域,因此也被称作GC堆(Garba- | % r , j o [ 5ge Collected Heap).从垃圾回收的角度,由于现在收集器基E 4 T本都采用分代垃圾收集算法,所以Java堆还可g G G y * g以细分为:新生代和老年代:再细致一点有:O v / vEden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
在 JDK 1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域(永久代使用的是Jo : $ | U hVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)。
推荐阅读:
- 《Java8内存模型—永久代(PermGen)和元空间(Metaspace)》:http://www.w } ! Hcnblogs.com/paddix/p/5309550.html
2.5 方法区
方法区与 Java 堆一样,是各个线程共享的y x q /内存区域,它用于存储已被虚拟机F L &加载的类信息、常量、静态g V m w变量、即时编译器编译后的代码等数据。虽然JB ; Y 6 G R m ~ava虚拟机% W 6 X a Z l规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 JaW 9 $ 5 z = !va 堆区分开来。
HotSpot 虚拟机中方法区也常被称为 “永久代8 ; i w p 1 6 M 0”,本质上两者并不等价。仅仅是c & j * 6 /因为 HotSpot$ / _ 虚拟机设计团队用永久代来实现方法区而已,这样 HotSpot 虚拟机的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了。但是这并不是一7 9 k L v . d ^ 个好主意,因为这样更容易遇到内存溢出问题。
相对而言,垃O D n M圾收集行u Q a 5 I ; F [为在这个区域是比较少出现的,但并非数据进入方法区后就“永久u E P存在”了。
2{ T `.6 运行时常量池
运行时常量池是方法区的一部分。Class 文件 + 6中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用% I U)
既然运行时常量池时方法区的一部分,自然受到方法区内存的限制,当W # : f I E I P常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
JDK1.7及P $ ~ + S之后版本的 JVM 已经将运w % % ( & C t t行时常量池从方8 s % r h m法区中移了出来,在 Java 堆(Heap)中开9 a B a C C ! S e辟了一块区域存放运行时常量池@ 2 6 V g。
——图片来源:https://blog.csdn.net/wangbp q o % y tiao007/article/details& X 5/78545189
推荐阅读:
- 《Java 中几种常量池的区分》: https://bB S I ilogp z b 9 N A - Q.csdn.net/; R J g x }qq_26222859/article/details/73135660
2.7 直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 异常出现。
JDK1.4中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓= S P =存区(Buffer) 的 I/O 方式,它可以直接使用Native函{ ? : ?数库直接分配堆外内存,然后通过一个存储在 JavE f f la 堆g [ r |中的 DirectByteBuffer 对象作为这c v e块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Javy F * } e , T 5 _a 堆和 Native 堆之间来回复制数据。
本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
3 HotSpot 虚拟机对象探秘
通过上面的介绍我们大概知道了虚拟机的内存情况,下M ; b U y H ] T面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过y w | O Y # :程。
3.1 对象的创建
下图便是 Java 对象的创建过程,我建议最好是能默写出来,并且要掌握] E - L Z ~ P每一步在做什么。
①类加载检查: 虚拟机遇到一条 new 指令时,首先将去检查这S P个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
②分配内存: 在类加载检B } C查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象h f - t e U C分配空间的任务等同于把一块确定9 j 6 -大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择 f J M那种分配方式由 Java 堆是否规整决定,而Java堆是4 K d o G否规整又由所采用的垃圾收集器是否带有S q r t Z压缩整理功能决定。
内存分配的两种方式:(补充内容,需要掌握)
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是\"标记-清除e m g\",还是\"标记-整理\"(也称作\"标记-压缩\"),值得注意q N F J ] G的是,复制算法内存也是规整的
内存分配并发问题(补充内容,需要掌握)
在创建对象的时候有一个很重要的问题,就是线程) 2 4 L f安全,因为在实际开发过程中,创建对象是很a j G a j G频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机& e J s采用 CAS 配上失败重试的方式保证4 ! ( s 5 ? [ H B更新操作的原子性。
- TLAB: 为每一个线程预先在8 w D aEden区分{ Z , 1 ^ R ~ L D配一块儿内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配
③初始化零值:: % : + d l ] O 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作u l保证了对象的实例字段在5 b U N { f P g Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
④设置对象头: 初始化零值完成之f o S H :后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分y ! b代年龄等信息。 这些信息存放M * W在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用Q * , * X j l u偏向锁等,对象头会有不同的设置方2 % y $ Y ( & 0 ,式0 $ ` s f N # o ^。
⑤执行 init 方法: 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<1 H _ r T 5 o 2init> 方法还没有执行,所有的字/ 6 u段都还为零。所以一般来说,执行{ : O 7 C E M A @ new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行Q - F J初始化,这样一个真正可用的对象才算完全产生出来。
3.2 对象的内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为3块区域:对象头、实例数据和对齐填充。
H. w [ / 2otspot虚拟机的对象头包括两部分, F @ b信息,第一部分用于存储对象自身的自身Q V a [ ` 6 y = 6运行时数据(哈希码、GC分代年龄、锁状态标志等等),另一部分是类型指针,即对W u X Q | 1 C ! p象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
对齐填充部分不是3 1 & E ( p f必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为Hotspot虚拟机的k @ r C自动内存管理系统要求V 1 ` T F I d !对象起始地址必须是8字节的整数倍,换句话说就是对象的 $ 8 x * V大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
3.3@ 9 ; o ; A @ A q 对 ? z P 2 } !象的访问定位
建立对象就是为了使用对象,v & 1 [ ~ ; 3我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有①使用句柄和②直接指针两种:
- 句柄: 如果使用句柄q { B B的话,那么Java堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而K 9 H $ =句柄中包含了对象实例数据与类型数据各自的具体地址信息O 6 P S;
- 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference 中存储的直接就H b [是对象的地址。
这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销{ . } } b s V S 1。
四 重点补充内容
String 类和常量池
1 String 对象的两种创建p ~ /方式:
Stri m Xng str1 = \"abcd\";
String str2 = new String(\"abcd\");
System.out.println(str1==str2);//false
这两种不同的创建方法是有差别的,第一种方式是在常量池@ ! # ) {中拿对象,H V [ ^ n ^ X o [第二种方式是直接在堆内存空间创建一个新的对象。
记住:只要使用new方法,便需要创建新的对象。
2 String 类型的常量池比较特殊。它的主要使用方法有两种:
- 直接使V _ U ,用双引号声明出来的 String 对象会直接存储在常L W p Q i + k ( a量池中。
- 如果不是用双引号声明的 String 对象,可以使用 String 提供的m ) : - 7 intern 方法。String.intern() 是一个% [ B d ( Nat@ @ 6 1 live 方法,它的作9 ? 8 X e用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,则在常量池中创建K & n G W A P g C与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用。
String s1 = new Stri M c @ { C Lng(\U 7 ~ X ] 1 : z"计算机\");
String s2 = s1.intern();
String s3 = \"计算机x c ` { j Q\";
System.out.println(s2);//计算机
System.out.println(s1 == s2);//false,因为一个是堆内存中的String对象一个是常量` 1 x池中的String对象,
System.out.println(s3 == s2);//true,因为两个都是常量池中的String对象
3 String 字L ~ 6 s y符串拼接
String str1 = \"str\";
String str2 = \"ing\";
String str3 = \"stR 1 H pr\" + \"ing\";//常量池中的对象
String str4 = str1 + str2; //在堆上创建的新的对象
String str5 = \"stringo b G B s\";//常量池中的对象
System.out.println(str3& s ` x f F { g == str4);//false
System.out.println(str3 == str5);//true
System.out.p] G A m X - | Mrintln(stO e [ [ ] ? ( A yr4 == str5);//fq 1 p i O z ~alse
尽量避免多个字符串拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 StringBuilder 或者 StringBuffer。
String s1 = n U % o & c 2ew Strinu R o J I ^ b ]g(\"abc\");这句话创建了几个对象?
创建了两个对象。
验证:
String s1; G w 5 D O = nz d & t f 9ew String(\"abc\");// 堆内存的地址值
String s2 = \"abc\";
System.out.println(s1 == s2Q r _ O + .);// 输出false,因为一个是堆内存,一个是常量池+ . b C #的内存,故两者是不同的。
System.out.println(s1.equals(s2));// 输出trup q 2 M Re
结果:
false
truz A K ; & z l A Je
解释:
先有字符串\"abc\"放入常量池,然后 new 了一份字符串\"abc\"放入Java堆(字符串常量\"abc\"在编译期就已经确定放入常量池,而 Java 堆上的\"abc\"是在运行{ 4 ! K # T q {期初始化阶段才确定),然后 Java 栈的 str1 指向Java堆上的\"abc\"。
8种基本类型的: J [ * B包装类和常量池
- Java 基本类型的包装类的大部分都实现了常量池技术J 4 X W,即Byte,Short,Integer,Long,Character,Boolean;这5种包装类默认创建了数值[-128,1278 r f]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。
- 两种浮点数类型的包装类 Float,Doubl6 2 V @ He 并没有实现常量池技术。
Integer i1 = 3Q ) G e b N 9 P3;
Integer i2 = 33;
System.out.println(i1 == i2);j J// 输出true
Integer i11 = 333;
Integer i22 = 333;
System.oW z /ut.println(i11 == i22);f f H ? k R s// 输出false
DouX * H ` f S H D Bble i3 = 1.2;
Double i4 = 1.2;
System.out.println(i3 == i4);// 输出F d |false
Integer 缓存源代码:
/**
*此方法将始终K G S T u M p A缓存-128到127(包括端点)范围内的值,并可以缓存此范围之外的其他值。
*/
public static Integer valueOf(intY I v [ i) {
if (i >= IntegerCache.low && i <= I* 2 + ntegerCacp L U k ` r n yhe.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new I4 0 { r / , Snteger(i);
}
应用场景:
- Integer i1=40;Java 在编译的时4 % U | u } Y J候会直接将代码封装成Integer i1=Integer.valueOf(40);,从而使用常量池中的对象。
- Integer i1 = new Integer(40);这种情况下会创建新的对象。
Integer i1 = 40;
Integeru 9 # C ! i2 = new Integer(40);
System.out.println(i1==i2);//输出false
Integer比较更丰富的一个例子:
Integer i1 = 40;
Integer i2 = 40;
Integer i3 = 0;
IntegR h L ter i4 = new Integer(40);
Integer i5 = new Intej ) ^ W =ger(40);
Integer i6 = new Integer(0);
Sa G D ] ? b u nystem.out.println(\"i1=i2 \" + (i1 == i2));
System.f z w u | @ S 6out.println(\"i1m r F k M s $=i2, ^ n+i3 \" + (i1 == i2 + i3));
System.out.println(\"i1=i4 \" + (i1 == i4));
System.out.Q T U |println(\"i4=i5 \" + (i4 == i5));
System.out.println(\"i4=i5+i6 \" + (i4 == i5 + i6));
System.outt h X 6 j O N N H.p8 9 Xrintln(\"9 7 i t B r A P ;40=i5+i6 \" + (40 == i5 + i6));
结果:5 t 0 / # = P & I
i1=i2 true
i1=i2+i3W J K ) true
i1=iR d : I (4 false
i4=i5 false
i4=i5+i6 true
40=i5+i6 true
解释:
语句i4 == i5 + i6,因为s N K - [ U 8+这个操作符不适用于Integer对象,首先i5和i6进行自动拆箱操作,进行数值相加,即i4 == 40。然后Integer对象无法与数值进行直接比较,所以i4自动拆箱转为int值40,最终这条语句转为40 == 40进行数值比较。