java内存分配和String类型的深度解析

摘要

在java语言的所有数据类型中,String类型是比较特殊的一种类型,同时也是使用频率非常高的数据类型,本文结合java内存分配分析一下关于String的一些问题。


一.摘要

在java语言的所有数据类型中,String类型是比较特殊的一种类型,同时也是使用频率非常高的数据类型,本文结合java内存分配分析一下关于String的一些问题。下面对文本将要说明的问题,如果您已经熟悉这一块,您可以忽略本文。

1、java内存具体指哪块内存?这块内存区域为什么要进行划分?是如何划分的?划分之后每块区域的作用是什么?如何设置各个区域的大小?

2、String类型在执行连接操作时,效率为什么会比StringBuffer或者StringBuilder低?StringBuffer和StringBuilder有什么联系和区别?

3、java中常量是指什么?String s = “s” 和 String s = new String(“s”) 有什么不一样?

本文收集了了相关的数据和网络上相关的讲解进行了整理和归纳,最终撰写成文,如果有不足或者错误之处,请多多指教!

二、java内存分配

1、JVM简介

Java虚拟机(Java Virtual Machine 简称JVM)是运行所有Java程序的抽象计算机,是Java语言的运行环境,它是Java 最具吸引力的特性之一。Java虚拟机有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。JVM屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。

一个运行时的Java虚拟机实例的职责是:负责运行一个java程序,当启动一个Java程序时,一个虚拟机实例也就诞生了,当该程序关闭退出,这个虚拟机实例也就随之消亡,如果同一台计算机上同时运行三个Java程序,将得到三个Java虚拟机实例,每个Java程序都运行于它自己的Java虚拟机实例中。

如下图所示,JVM的体系结构包含几个主要的子系统和内存区:

垃圾回收器(Garbage Collection):负责回收堆内存(Heap)中没有被使用的对象,即这些对象已经没有被引用了。


类装载子系统(Classloader Sub-System):除了要定位和导入二进制class文件外,还必须负责验证被导入类的正确性,为类变量分配并初始化内存,以及帮助解析符号引用。


执行引擎(Execution Engine):负责执行那些包含在被装载类的方法中的指令。


运行时数据区(Java Memory Allocation Area):又叫虚拟机内存或者Java内存,虚拟机运行时需要从整个计算机内存划分一块内存区域存储许多东西。例如:字节码、从已装载的class文件中得到的其他信息、程序创建的对象、传递给方法的参数,返回值、局部变量等等。

下图就是JVM的结构图:

2、java内存分区

从上节知道,运行时数据区即是java内存,而且数据区要存储的东西比较多,如果不对这块内存区域进行划分管理,会显得比较杂乱无章。程序喜欢有规律的东西,最讨厌杂乱无章的东西。 根据存储数据的不同,java内存通常被划分为5个区域:程序计数器(Program Count Register)、本地方法栈(Native Stack)、方法区(Methon Area)、栈(Stack)、堆(Heap)。

程序计数器(Program Count Register):又叫程序寄存器。JVM支持多个线程同时运行,当每一个新线程被创建时,它都将得到它自己的PC寄存器(程序计数器)。如果线程正在执行的是一个Java方法(非native),那么PC寄存器的值将总是指向下一条将被执行的指令,如果方法是 native的,程序计数器寄存器的值不会被定义。 JVM的程序计数器寄存器的宽度足够保证可以持有一个返回地址或者native的指针。

栈(Stack):JVM为每个新创建的线程都分配一个栈,也就是说,对于一个Java程序来说,它的运行就是通过对栈的操作来完成的,栈以帧为单位保存线程的状态。JVM对栈只进行两种操作:以帧为单位的压栈和出栈操作,我们知道,某个线程正在执行的方法称为此线程的当前方,当前方法使用的帧称为当前帧,当线程激活一个Java方法,JVM就会在线程的 Java栈里新压入一个帧,这个帧自然成为了当前帧,在此方法执行期间,这个帧将用来保存参数、局部变量、中间计算过程和其他数据。从Java的这种分配机制来看,栈又可以这样理解:栈(Stack)是操作系统在建立某个进程时或者线程(在支持多线程的操作系统中是线程)为这个线程建立的存储区域,该区域具有先进后出的特性。其相关设置参数:

-Xss –设置方法栈的最大值


本地方法栈(Native Stack):存储本地方方法的调用状态。

方法区(Method Area):当虚拟机装载一个class文件时,它会从这个class文件包含的二进制数据中解析类型信息,然后把这些类型信息(包括类信息、常量、静态变量等)放到方法区中,该内存区域被所有线程共享,如下图所示。本地方法区存在一块特殊的内存区域,叫常量池(Constant Pool),这块内存将与String类型的分析密切相关。

堆(Heap):Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域。在此区域的唯一目的就是存放对象实例,几乎所有的对象实例都是在这里分配内存,但是这个对象的引用却是在栈(Stack)中分配,因此,执行String s = new String(“s”)时,需要从两个地方分配内存:在堆中为String对象分配内存,在栈中为引用(这个堆对象的内存地址,即指针)分配内存,如下图所示。

JAVA虚拟机有一条在堆中分配新对象的指令,却没有释放内存的指令,正如你无法用Java代码区明确释放一个对象一样。虚拟机自己负责决定如何以及何时释放不再被运行的程序引用的对象所占据的内存,通常,虚拟机把这个任务交给垃圾收集器(Garbage Collection)。其相关设置参数:

  • -Xms — 设置堆内存初始大小

  • -Xmx — 设置堆内存最大值

  • -XX:MaxTenuringThreshold — 设置对象在新生代中存活的次数

  • -XX:PretenureSizeThreshold — 设置超过指定大小的大对象直接分配在旧生代中

Java堆是垃圾收集器管理的主要区域,因此又称为“GC 堆”(Garbage Collectioned Heap)。现在的垃圾收集器基本都是采用的分代收集算法,所以Java堆还可以细分为:新生代(Young Generation)和老年代(Old Generation),如下图所示。分代收集算法的思想:第一种说法,用较高的频率对年轻的对象(young generation)进行扫描和回收,这种叫做minor collection,而对老对象(old generation)的检查回收频率要低很多,称为major collection。这样就不需要每次GC都将内存中所有对象都检查一遍,以便让出更多的系统资源供应用系统使用;另一种说法,在分配对象遇到内存不足时,先对新生代进行GC(Young GC);当新生代GC之后仍无法满足内存空间分配需求时, 才会对整个堆空间以及方法区进行GC(Full GC)。

在这里可能会有读者表示疑问:记得还有一个什么永久代(Permanent Generation)的啊,难道它不属于Java堆?亲,你答对了!其实传说中的永久代就是上面所说的方法区,存放的都是jvm初始化时加载器加载的一些类型信息(包括类信息、常量、静态变量等),这些信息的生存周期比较长,GC不会在主程序运行期对PermGen Space进行清理,所以如果你的应用中有很多CLASS的话,就很可能出现PermGen Space错误。其相关设置参数:

  • -XX:PermSize –设置Perm区的初始大小

  • -XX:MaxPermSize –设置Perm区的最大值

新生代(Young Generation又分为:Eden区和Survivor区,Survivor区有分为From Space和To Space。Eden区是对象最初分配到的地方;默认情况下,From Space和To Space的区域大小相等。JVM进行Minor GC时,将Eden中还存活的对象拷贝到Survivor区中,还会将Survivor区中还存活的对象拷贝到Tenured区中。在这种GC模式下,JVM为了提升GC效率, 将Survivor区分为From Space和To Space,这样就可以将对象回收和对象晋升分离开来。新生代的大小设置有2个相关参数:

-Xmn — 设置新生代内存大小。

-XX:SurvivorRatio — 设置Eden与Survivor空间的大小比例


老年代(Old Generation): 当 OLD 区空间不够时, JVM 会在 OLD 区进行 major collection;完成垃圾收集后,若Survivor及OLD区仍然无法存放从Eden复制过来的部分对象,导致JVM无法在Eden区为新对象创建内存区域,则出现”Out of memory错误”  。

三、String类型的深度解析

让我们从Java数据类型开始说起吧!Java数据类型通常(分类方法多种多样)从整体上可以分为两大类:基础类型和引用类型,基础类型的变量持有原始值,引用类型的变量通常表示的是对实际对象的引用,其值通常为对象的内存地址。

String m = "hello,world";
String n = "hello,world";
String u = new String(m);
String v = new String("hello,world");


这些语句会发生什么事情? 大概是这样的:

1.会分配一个11长度的char数组,并在常量池分配一个由这个char数组组成的字符串,然后由m去引用这个字符串。

2.用n去引用常量池里边的字符串,所以和n引用的是同一个对象。

3.生成一个新的字符串,但内部的字符数组引用着m内部的字符数组。

4.同样会生成一个新的字符串,但内部的字符数组引用常量池里边的字符串内部的字符数组,意思是和u是同样的字符数组。

如果我们使用一个图来表示的话,情况就大概是这样的(使用虚线只是表示两者其实没什么特别的关系):

对象在内存中的布局

结论就是,m和n是同一个对象,但m,u,v都是不同的对象,但都使用了同样的字符数组,并且用equal判断的话也会返回true。

我们可以使用反射修改字符数组来验证一下效果,可以试试下面的测试代码:

@Test
public void test1() throws Exception {
    String m = "hello,world";
    String n = "hello,world";
    String u = new String(m);
    String v = new String("hello,world");
 
    Field f = m.getClass().getDeclaredField("value");
    f.setAccessible(true);
    char[] cs = (char[]) f.get(m);
    cs[0] = 'H';
 
    String p = "Hello,world";
    Assert.assertEquals(p, m);
    Assert.assertEquals(p, n);
    Assert.assertEquals(p, u);
    Assert.assertEquals(p, v);
}


从上面的例子可以看到,经常说的字符串是不可变的,其实和其他的final类还是没什么区别,还是引用不可变的意思。 虽然String类不开放value,但同样是可以通过反射进行修改,只是通常没人这么做而已。 即使是涉及”修改”的方法,都是通过产生一个新的字符串对象来实现的,例如replace、toLower、concat等。 这样做的好处就是让字符串是一个状态不可变类,在多线程操作时没有后顾之忧。

当然,在字符串修改的时候,会产生一个新的对象,如果执行很频繁,就会导致大量对象的创建,性能问题也就随之而来了。 为了应付这个问题,通常我们会采用StringBuffer或StringBuilder类来处理。

另外,字符串常量通常是在编译的时候就确定好的,定义在类的方法区里边,也就是说,不同的类,即使用了同样的字符串, 还是属于不同的对象。所以才需要通过引用字符串常量来减少相同的字符串的数量。可以通过下面的代码来测试一下:

class A {
    public void print() {
        System.out.println("hello");
    }
}
 
class B {
    public void print() {
        String s = "hello";
        // 修改s的第一个字符为H
        System.out.println("hello"); // 输出Hello
        new A().print(); // 输出hello
    }
}

字符串操作细节

String类内部处理有个字符数组之外,还使用偏移位置offset和长度count, 通过offset和count来确定字符数组的一部分,这部分才是这个字符串的真正的内容。 例如,有substring这个常用方法,看下面的例子:

1
2
3
String m = "hello,world";
String u = m.substring(2,10);
String v = u.substring(4,7);

按照上面的说法,m,n的数据结构就如下图所示:

substring在内存中的布局

可以发现,m,n,v是三个不同的字符串对象,但引用的value数组其实是同一个。 同样可以通过上述反射的代码进行验证,这里就不详述了。

但字符串操作时,可能需要修改原来的字符串数组内容或者原数组没法容纳的时候,就会使用另外一个新的数组,例如replace,concat,+等 操作。另外,oracle的JDK实现中,String的构造方法,对于字符串参数只是引用部分字符数组的情况(count小于字符数组长度),采用的是 拷贝新数组的方式,是比较特别的,不过这个构造方法也没什么机会使用到。

例如下面的代码:

1
2
3
String m = "hello,";
String u = m.concat("world");
String v = new String(m.substring(0,2));

得到的结构图如下:

新字符数组在内存中的布局

可以发现,m,u,v内部的字符数组并不是同一个,有兴趣可以试验一下。

常量池中字符串的产生

常量池中的字符串通常是通过字面量的方式产生的,就像上述m语句那样。 并且他们是在编译的时候就准备好了,类加载的时候,顺便就在常量池生成。

可以通过javap命令检查一下class的字节码,可以发现下面的高亮部分(以上面代码为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
javap -v StringTest
 
 Compiled from "StringTest.java"
 public class com.github.mccxj.StringTest extends java.lang.Object
   SourceFile: "StringTest.java"
   minor version: 0
   major version: 50
   Constant pool:
 const #1 = Method       #9.#28; //  java/lang/Object."<init>":()V
+ const #2 = String       #29;    //  hello,
+ const #3 = String       #30;    //  world
 ...
+ const #46 = Asciz       hello,;
+ const #47 = Asciz       world;
 ...

大家不知有没有发现,上面的图中,u和v的字符数组没有被常量池里边的字符串引用到。 原因就是这些字符串(字符数组)都是运行时生成的,而常量池里边的字符串和字符数组是完整对应上的(count等于数组长度)。

即使是字符串的内容是一样的,都不能保证是同一个字符串数组。例如下面的代码:

1
2
3
String m = "hello,world";
String u = m + ".";
String v = "hello,world.";

u和v虽然是一样内容的字符串,但内部的字符数组不是同一个。画成图的话就是这样的:

不同字符数组在内存中的布局

另外有一点,如果让m声明为final,你就会发现u和v变成是同一个对象。画成图的话就是这样的:

u和v在内存中的布局

这应该怎么解释的?这其实都是编译器搞的鬼,因为m是final的, u直接被编译成”hello,world.”了,如果使用javap查看的话,会发现下面一段逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
const #2 = String       #25;    //  hello,world
const #3 = String       #26;    //  hello,world.
...
public void test1()   throws java.lang.Exception;
  Code:
   Stack=1, Locals=4, Args_size=1
   0:   ldc     #2; //String hello,world
   2:   astore_1
   3:   ldc     #3; //String hello,world.
   5:   astore_2
   6:   ldc     #3; //String hello,world.
   8:   astore_3
   9:   return

那么,如何让运行时产生的字符串放到常量池里边呢? 可以借助String类的intern方法。 例如下面的用法

1
2
3
String m = "hello,world";
String u = m.substring(0,2);
String v = u.intern();

上面我们已经知道m,n使用的是同一个字符数组,但intern方法会到常量池里边去寻找字符串”he”,如果找到的话,就直接返回该字符串, 否则就在常量池里边创建一个并返回,所以v使用的字符数组和m,n不是同一个。画成图的话就是这样的:

intern在内存中的布局

字符串的内存释放问题

像字面量字符串,因为存放在常量池里边,被常量池引用着,是没法被GC的。例如下面的代码:

1
2
3
4
5
String m = "hello,world";
String n = m.substring(0,2);
 
m = null;
n = null;

经过上述的操作,画成图的话就是这样的:

内存释放后的布局

而经过上面的分析,我们知道像substring、split等方法得到的结果都是引用原字符数组的。 如果某字符串很大,而且不是在常量池里存在的,当你采用substring等方法拿到一小部分新字符串之后,长期保存的话(例如用于缓存等), 会造成原来的大字符数组意外无法被GC的问题。

关于这个问题,常见的解决办法就是使用new String(String original)或java.io.StreamTokenizer类


IT家园
IT家园

网友最新评论 (0)