JVM优化系列-String对象在虚拟机中的实现

ゝ一纸荒年。 2023-07-07 14:56 61阅读 0赞

导语
  String字符串在是各种编程语言中都是重头戏。各种语言中对字符串的操作都是进行有特殊化的处理,例如在C语言中根本没有字符串这个概念,在C语言中的字符串是用字符数组来表示的。在Java中,String作为非基本数据类型,也就是引用数据类型,但是在使用的时候与基本数据类型的待遇是一样的。基于C语言对于字符串的操作,在Java虚拟机中内存的分配与回收也要进行对应的操作。下面就来分享关于字符串在虚拟机中的实现。

文章目录

    • String 对象的特点
      • 1、不变性
      • 2、针对常量池的优化
      • 3、类的final定义
    • 有关String 的内存泄露
    • 处理有关String常量池的问题

String 对象的特点

  在笔者之前看过一本书中看到在Java语言中,Java设计对于String对象进行了大量的优化,线程安全、不可变性等等,主要体现在以下的三个方面进行优化

  • 不变性(单一对象模式)
  • 针对常量池的优化
  • 类的final定义

1、不变性

  不变性是指String对象一旦生成了,就不能对其进行改变,也就是说有如下的操作

  1. public class SimpleString {
  2. public static void main(String[] args) {
  3. String a = "abs";
  4. System.out.println(a.hashCode());
  5. a = "ab";
  6. System.out.println(a.hashCode());
  7. }
  8. }

  从上面的输出结果可以看到,String自动封箱了一个对象,这个对象的状态在对象创建之后就不会在发生变化。不变模式主要用在多线程共享对象的时候,可以省略一些访问时间,也就是之前提到的,这个对象是线程安全的,从上面的代码中可以看出,上面的a对象和下面的a对象是在内存中是分开两个空间进行存储的。

  由于不变性,对于对象的修改操作就不能像是上面代码中显示的一样,看上去是一个简单的修改操作,实际上都是通过产生新的字符串来实现的,例如上面的这种操作以及,String.substring()、String.concat()方法,都是没有修改原来的字符串,而是在内存中产生了一个新的字符串。如果在需求中出现需要可变字符串的时候,就需要使用StringBuffer或者是StringBuilder,对于StringBuilder来说可以了解一下Builder设计模式。

2、针对常量池的优化

  了解过Java的内存模型的都知道,在内存中有个叫做常量池的地方,而对于String对象来说,如果出现两个对象值是一样的,那么在常量池中只保留一份数据。

  1. public class SimpleString {
  2. public static void main(String[] args) {
  3. String str1 = new String("abc");
  4. String str2 = new String("abc");
  5. System.out.println(str1==str2);
  6. System.out.println(str1==str2.intern());
  7. System.out.println("abc"==str2.intern());
  8. System.out.println(str1.intern()==str2.intern());
  9. }
  10. }

  通过上面代码可以画出如下的一个内存图,实际上str1和str2 都是开辟了一块堆内存空间,虽然str1和str2 内容是相同的,但是在堆中的引用是不一样的,String.intern()返回字符串在常量池中的引用,显然两个对象的引用是不一样的,但是从最后一段代码中可以看到,String.intern() 始终和常量字符串相等,同样也可以看到str1和str2的intern()也是相等的。
在这里插入图片描述

3、类的final定义

  之前面试的时候面试官问过这样的一个问题,String类型可以改变么?答案是不可以,因为是被final修饰的。也就是说final类型定义也是String对象的重要特点,作为final类的String对象在系统中不可能有任何的子类,这个是对系统安全性的保护。在JDK1.5 版本之前的环境中,使用final定义有助于帮助虚拟机寻找机会,内联所有的final方法,都可以实现高效率,但是优化的并不是效果明显。有兴趣的可以研究一下多线程单一对象模式。

有关String 的内存泄露

  对于内存泄露来讲,简单的说,就是由于疏忽或者错误造成程序未能正确的释放已经使用内存的情况,这个内存泄露并不是说没有内存了,而是说没有合理的使用内存。导致可用内存越来越少。

  对于C或者C++等一些语言来讲,Java语言的内存管理是通过JVM实现对象的自从创建和回收的,但是这个自动回收机制并不能说明内存就有不泄露的可能。下面就通过String来说明内存泄露的原因。在低版本的JDK中,String对象是由3部分组成的:代表字符数组的value、偏移量offset和长度size。
在这里插入图片描述
  通过分析这个结构来看,String实际的内容由value、offset和count三者共同所决定的,并不是只有value一项来决定。如果一个String对象它的value包含100个字符,而且长度只有一个字节,那么这个String实际上只有一个字符,但是确实占据了100个字节,那么剩余的99个就是内存泄露的部分,它不会被使用,当然也不会被释放,因为前面有一个字节的有效数据,直到这个字节被回收整个的内存使用才会被回收。如果这种情况太多的话就会导致内存泄露。下面就来分析substring();

  1. public String substring(int beginIndex, int endIndex) {
  2. if (beginIndex < 0) {
  3. throw new StringIndexOutOfBoundsException(beginIndex);
  4. }
  5. if (endIndex > value.length) {
  6. throw new StringIndexOutOfBoundsException(endIndex);
  7. }
  8. int subLen = endIndex - beginIndex;
  9. if (subLen < 0) {
  10. throw new StringIndexOutOfBoundsException(subLen);
  11. }
  12. return ((beginIndex == 0) && (endIndex == value.length)) ? this
  13. : new String(value, beginIndex, subLen);
  14. }

会看到其中调用了一个构造函数

  1. public String(char value[], int offset, int count) {
  2. if (offset < 0) {
  3. throw new StringIndexOutOfBoundsException(offset);
  4. }
  5. if (count <= 0) {
  6. if (count < 0) {
  7. throw new StringIndexOutOfBoundsException(count);
  8. }
  9. if (offset <= value.length) {
  10. this.value = "".value;
  11. return;
  12. }
  13. }
  14. // Note: offset or count might be near -1>>>1.
  15. if (offset > value.length - count) {
  16. throw new StringIndexOutOfBoundsException(offset + count);
  17. }
  18. this.value = Arrays.copyOfRange(value, offset, offset+count);
  19. }

这里与老版本的JDK不同的地方在于对值的处理,并不是把所有的值直接赋值,但是在优化之后的版本中value的处理是吧有用的地方进行了复制,未使用的地方则被省略掉,减少了内存溢出的风险。

处理有关String常量池的问题

  在虚拟机中,有一块被称为是常量池,在JDK1.6之前,这块区域属于永久区的一部分,在JDK1.7 就被归并到堆内存中进行管理

  1. public class StringInternOOM {
  2. public static void main(String[] args) {
  3. List<String> list = new ArrayList<>();
  4. int i = 0;
  5. while (true){
  6. list.add(String.valueOf(i++).intern());
  7. }
  8. }
  9. }

  上面代码中,可以看到intern() 方法获取在常量池中的字符串引用,如果常量池中的没有该常量字符串,该方法会将字符串加入到常量池中。然后,将该引用放入list持有,确保不会被回收。添加虚拟机参数

  1. -Xmx5m -XX:MaxPermSize=5m -XX:+PrintGCDetails

  在JDK1.8之后会有如下的报错,会看到提示 GC overhead limit exceeded 超过了GC的开销。

  1. [Full GC (Ergonomics) Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
  2. [PSYoungGen: 1023K->1023K(1536K)] [ParOldGen: 4037K->4037K(4096K)] 5061K->5061K(5632K), [Metaspace: 3100K->3100K(1056768K)], 0.0154810 secs] [Times: user=0.16 sys=0.00, real=0.01 secs]
  3. [Full GC (Ergonomics) at java.lang.Integer.toString(Integer.java:403)
  4. at java.lang.String.valueOf(String.java:3099)
  5. at com.nihui.stringtest.StringInternOOM.main(StringInternOOM.java:17)
  6. [PSYoungGen: 1023K->0K(1536K)] [ParOldGen: 4053K->385K(4096K)] 5077K->385K(5632K), [Metaspace: 3126K->3126K(1056768K)], 0.0028613 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]

可以参考 https://cloud.tencent.com/developer/article/1082687

  需要注意的的是,虽然intern() 的返回值永远等于字符串常量,但是并不代表在系统的任何时候,相同的字符串的intern()返回都是一样的,至少是95%以上上是相同的,因为在一次intern之后,改字符串在某一时刻被回收,之后在进行调用,字符串就又被加入到了常量池中,但是引用的位置已经不同了。

发表评论

表情:
评论列表 (有 0 条评论,61人围观)

还没有评论,来说两句吧...

相关阅读