在说字符串常量池之前,先了解下JVM的内存模型
I. JVM内存区域主要分为线程私有区域(橙色部分:虚拟机栈、本地方法栈、程序计数器)和线程共享区(绿色部分:Java堆、方法区)、直接内存。
线程私有区域生命周期和线程相同,依赖用户线程的启动、结束而创建和销毁(在hotspotVM中,每个线程都与操作系统的本地线程直接映射,这部分私有内存区域的存/否跟随本地线程的生/死对应)
- 直接内存
直接内存不是JVM运行时数据区的一部分。在JDK1.4引入的NIO提供了基于Channel和Buffer的IO方式,它可以通过Native函数库直接分配堆外内存,然后使用DirectByteBuffer对象作为这块内存的引用直接操作,以此避免了在java堆和Native堆中来回复制数据,在一些场景中能显著提高性能。
- 程序计数器
一块较小的内存空间,是当前线程所执行的字节码的行号指示器,每个线程都会有一个独立的程序计数器。可以认为,当方法入栈后,每一行代码都有一个标识,程序按照标识往下执行。正在执行的java方法的话,计数器记录的是虚拟机字节码指令的地址,如果是native方法,则为空,这个内存区域也是唯一一个虚拟机中没有规定任何OutOfMemoryError情况的区域。
- java虚拟机栈
描述java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧-Stack Frame,用于存储局部变量表(Local Variable)、操作数栈(Operand Stack)、动态链接(Dynamic Linking)、方法出口等信息。方法调用进栈,方法结束出栈,每一个方法从调用到执行完成的过程,都对应着一个栈帧在java虚拟机栈中入栈到出栈的过程。
栈帧用于存储数据和部分过程结果的数据结构,同时也被用于处理动态链接、方法返回值和异常分派(Dispatch Exception)。局部变量表里存储着基本数据类型和引用类型等,所需的内存空间在编译期就完成了分配,在运行期是不会改变的。
无论方法正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。栈很容易出现StackOverFlowError,常见的操作是递归操作。①线程请求的栈深度大于JVM所允许的深度,StackOverFlowError。②JVM允许动态扩展,若无法申请到足够内存,OutOfMemoryError。
- 本地方法栈
本地方法区和 Java Stack 作用类似, 区别是虚拟机栈为执行 Java 方法服务, 而本地方法栈则为Native 方法服务, 如果一个 VM 实现使用 C-linkage 模型来支持 Native 调用, 那么该栈将会是一个C 栈,但 HotSpot VM 直接就把本地方法栈和虚拟机栈合二为一。
- 堆Heap
是线程共享的一块区域。创建的对象和数组都保存在Java堆内存中(暂时都认为都分配在堆上,以后考虑栈分配和TLAB分配),堆同时也是垃圾收集器进行垃圾回收的最重要的内存区域。由于现代VM采用的是分代收集算法,堆在GC角度分为新生代(Eden区、From Survivor(s0)和To Survivor(s1))和老年代。
一、新生代
用来存放新生的对象,一般占用堆得1/3空间,由于频繁的创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。
①Eden区
java新对象的出生地(如果创建的对象占用内存很大,则会直接分配到老年代),当Eden区内存不够时就会触发MinorGC,对新生代进行一次垃圾回收。
②S0区
上一次GC的幸存者,作为这一次GC的被扫描者。
③S1区
保留了一次MinorGC过程中的幸存者。
------〉MinorGC的过程(复制-清空-互换)
MinorGC采用复制算法。1.首先把Eden和S0区中存活的对象复制到S1中,如果有对象的年龄达到了老年的标准,就被赋值到老年代,复制到S0的对象年龄+1(默认年龄15就可以进入老年代),如果此时S0区域不够,则直接放置到老年代。2.清空Eden区和S0区。3.S1和S0互换,原S0作为下一次GC的S1区。
二、老年代
该区域主要放置生命周期长的对象,老年代的对象比较稳定,所以MajorGC不会频繁的执行,在进行MajorGC之前一般都会进行了一次MinorGC,使得新生代的对象有能够进入老年代导致空间不足触发。当无法找到足够的连续空间分配给较大的对象时也会触发MajorGC进行垃圾回收腾出空间。
------〉MajorGC的过程(标记-清除)
MajorGC采用标记清除算法。扫描一次所有的老年代对象,标记出存活的对象,回收没有标记的对象,MajorGC耗时较长,因为要先扫描再回收,MajorGC还会产生内存碎片,为了减少内存消耗,一般需要进行合并和标记方便下次直接分配,当老年代都装不下的时候就会出现OOM异常。
- 方法区
用于存储被JVM加载的类信息、常量、静态变量、静态代码块、即时编译期编译后的代码等信息,以前方法区也被成为永久带,HotspotVM将GC扩展到方法区,使用java堆的永久代实现方法区,这样垃圾回收器就可以像管理堆一样管理这部分内存,永久代的垃圾回收主要针对常量池的回收和类型的卸载,收益很小。
在Java8中,永久代被移除,被一个叫元数据区(Meta space)代替,元空间和永久代类似,最大的区别在于:元空间不在虚拟机中,而是使用本地内存,这样的话,元空间的大小仅受本地内存限制,类的元数据放入native memory,字符串池和类的静态变量放入java堆,加载多少类的元数据不在受MaxPermSize控制,而是本地内存限制。
运行时常量池(Runtime Constant Pool)是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项重要的信息就是常量池(Constant Pool Table),用于存放编译期生成的各种字面值和符号引用,这部分内容将随类的加载存放在方法区的运行时常量池,因此运行时常量池位于堆上(指字面值和符号引用等这部分内容)。
II. 常量池
常量池里存放着字面值和符号引用。
字面值就是我们常说的常量,如String a = "hello",hello就是字面值。
符号引用主要包括,
1.类和接口的全限定名,例如对于String这个类,它的全限定名就是java/lang/String。
2.字段的名称和描述符,所谓的字段就是类或者接口中声明的变量,包括类级别变量static和实例级别的变量。
3.方法的名称和描述符,描述符就相当于方法的参数类型+返回值类型。
当类加载器加载Class文件后,class文件中的常量池就会随着类加载进入方法区中的运行时常量池(内存中),这个常量池是全局共享的,多个类公用一个,并且相同的字符串在运行时常量池只会存在一份。
在1.7以后,字符串常量池放在了堆上,类的描述信息还在方法区,方法区的实现从永久代变成了元空间。
字符串的分配和其他对象分配一样,耗费昂贵的时间和空间代价,那么字符串在创建的时候会被优化,为了减少在JVM堆上创建字符串的数量,维护了一个字符串池。每当创建字符串常量时,JVM要先检查字符串常量池,如果字符串已经在池中,则直接返回在池中的实例引用,如果字符串不在池中,就会实例化一个字符串并放在池中返回引用(1.7)。
先了解下String的intern()方法,这个方法是判断常量是否在常量池中。
1.7之前,判断常量是否在字符串常量池中,如果存在直接返回该常量,如果没有,则将该字符串常量加入到字符串常量池中,并将引用指向常量池对应的该常量。
1.7及以后,判断常量是否在字符串常量池中,如果存在直接返回该常量,如果没有(此时该字符串对象一定在堆区),则将该引用加入到字符串常量池中,如果以后还有该字符串的创建(会做equals对比,应该有一个类似于记录表的结构记录了引用的存放位置,对比后返回第一个符合条件的引用,这里我有疑问),则直接返回常量池中的该引用或者常量。
即
常量是否存在于常量池
如果存在 {
判断存在内容是引用还是常量{
如果是引用{
返回引用指向堆空间对象的地址
}
如果是常量{
直接返回常量池常量
}
}
}
如果不存在{
将当前对象引用复制到常量池,并且返回当前对象的引用
}
对于s1,相当于创建两个对象,new出来的对象在堆上,hehe常量在常量池中,s1指向堆上的对象引用。
对于s2,直接指向的是常量池中的hehe。
对于s2,也是直接指向常量池中的hehe
因此s1==s3为false,s2==s3为true
对于s4,底层通过StringBuilder将两个hehe拼接到一起,然后调用toString()方法new出hehehehe,此时s4是在堆上。
s4.intern()后,会将s4的引用存放在常量池中
对于s5,会指向s4在堆上的地址。
因此s4和s5指向同一个内存地址,s4==s5为true。
思考: