>Java >Java베이스 >과거를 복습하고 새로운 것을 배운다 (1) Java의 문자열에 대한 심층적 이해

과거를 복습하고 새로운 것을 배운다 (1) Java의 문자열에 대한 심층적 이해

coldplay.xixi
coldplay.xixi앞으로
2020-09-18 17:32:242826검색

과거를 복습하고 새로운 것을 배운다 (1) Java의 문자열에 대한 심층적 이해

관련 학습 권장 사항: Java 기본 튜토리얼

Java를 처음 배웠을 때 우리는 Java가 두 가지 주요 데이터 유형, 즉 기본 데이터 유형과 참조 데이터 유형으로 나눌 수 있다는 것을 이미 알고 있었습니다. 이 두 가지 데이터 유형 중에는 특별한 데이터 유형인 String이 있습니다. String은 참조 데이터 유형이지만 다른 참조 데이터 유형과 다릅니다. 데이터형 중에서는 특이한 꽃이라고 할 수 있다. 따라서 이 기사에서는 Java의 문자열 문자열에 대해 심층적으로 이해하겠습니다.

1. 문자열 문자열의 메모리 할당부터 시작하겠습니다

이전 글 "과거를 복습하고 새로운 것을 배우기 - 당신이 모르는 JVM 메모리 할당"에서는 JVM 메모리 모델을 자세히 분석했습니다. 상수 풀 섹션에서는 문자열 상수 풀, 클래스 파일 상수 풀, 런타임 상수 풀이라는 세 가지 유형의 상수 풀에 대해 배웠습니다. 문자열의 메모리 할당은 문자열 상수 풀과 큰 관계가 있습니다.

문자열을 인스턴스화하는 방법에는 두 가지가 있습니다. 가장 일반적으로 사용되는 첫 번째 방법은 리터럴 할당을 이용하는 것이고, 다른 하나는 매개변수를 전달하는 구성 방법을 이용하는 것입니다. 코드는 다음과 같습니다.

    String str1="abc";
    String str2=new String("abc");复制代码

메모리 할당에서 이 두 가지 방법의 차이점은 무엇입니까? 선생님이 Java를 처음 배웠을 때 설명하신 것 같습니다.

1 리터럴 할당을 통해 문자열을 생성합니다. 문자열 상수 풀의 개체입니다. 2. 생성자를 통해 String 매개변수를 전달하면 힙 메모리와 문자열 상수 풀에 String 개체가 생성되고 힙 메모리의 String 참조가 스택에 저장됩니다.

이 답변이 맞나요? 사용되는 Java 버전에 전적으로 의존하기 때문에 적어도 지금은 완전히 정확하지 않은 것 같습니다. 이전 기사 "과거를 검토하고 새로운 것을 배우십시오 - 당신이 모르는 JVM 메모리 할당"에서는 HotSpot 가상 머신의 다양한 JDK에서의 문자열 상수 풀 구현에 대해 설명했습니다. 발췌 내용은 다음과 같습니다.

JDK7 이전에는 문자열이 있습니다. 상수 풀은 메소드 영역(영구 생성)에 있으며 이때 상수 풀은 문자열 객체를 저장합니다. JDK7에서는 문자열 상수 풀이 메서드 영역에서 힙 메모리로 이동되고 문자열 개체는 Java 힙에 저장됩니다. 문자열 상수 풀은 문자열 개체에 대한 참조만 저장합니다.

이 문장을 어떻게 이해해야 할까요? 분석을 위해 String str1=new String("abc")을 예로 들어 보겠습니다.

1. JDK6의 메모리 할당

먼저 아래 그림과 같이 JDK6의 메모리 할당을 분석해 보겠습니다.

new String("abc")이 호출되면 Java 힙 및 상수 풀에 "abc" 객체가 생성됩니다. 동시에 str1이 힙의 "abc" 개체를 가리키도록 합니다. 과거를 복습하고 새로운 것을 배운다 (1) Java의 문자열에 대한 심층적 이해
2. JDK7의 메모리 할당
JDK7 이상 버전에서는 문자열 상수 풀이 힙 메모리로 이동되므로 메모리 할당 방법도 다음 그림과 같이 다릅니다.

new String("abc")이 호출되면 힙 메모리에 두 개의 "abc" 객체가 생성되고, str1은 "abc" 객체 중 하나를 가리키며, "abc" 객체에 대한 참조가 상수에 생성됩니다. 풀, 다른 "abc" 개체를 가리킵니다. 과거를 복습하고 새로운 것을 배운다 (1) Java의 문자열에 대한 심층적 이해
Java가 이렇게 설계된 이유는 이전 기사에서 이미 설명했습니다.
String은 Java에서 가장 자주 사용되는 데이터 유형이므로 프로그램 메모리를 절약하고 프로그램 성능을 향상시키기 위해 Java 디자이너는 A를 개발했습니다. 문자열 상수 풀 영역이 생성됩니다. 이 영역은 모든 클래스에서 공유됩니다. 각 가상 머신에는 하나의 문자열 상수 풀만 있습니다. 따라서 리터럴 할당을 사용할 때 문자열이 문자열 상수 풀에 이미 존재하는 경우 개체는 힙 메모리에서 다시 생성되지 않고 문자열 상수 풀의 개체를 직접 가리킵니다.

2. String의 intern() 메소드

String의 메모리 할당을 이해한 후에는 String에서 매우 중요한 메소드인 String.intern()을 알아야 합니다.

많은 독자들이 이 방법에 대해 잘 알지 못할 수도 있지만 이것이 중요하지 않다는 의미는 아닙니다. 먼저 intern() 메서드의 소스 코드를 살펴보겠습니다.

/**
     * Returns a canonical representation for the string object.
     * <p>
     * A pool of strings, initially empty, is maintained privately by the
     * class {@code String}.
     * <p>
     * When the intern method is invoked, if the pool already contains a
     * string equal to this {@code String} object as determined by
     * the {@link #equals(Object)} method, then the string from the pool is
     * returned. Otherwise, this {@code String} object is added to the
     * pool and a reference to this {@code String} object is returned.
     * <p>
     * It follows that for any two strings {@code s} and {@code t},
     * {@code s.intern() == t.intern()} is {@code true}
     * if and only if {@code s.equals(t)} is {@code true}.
     * <p>
     * All literal strings and string-valued constant expressions are
     * interned. String literals are defined in section 3.10.5 of the
     * <cite>The Java&trade; Language Specification</cite>.
     *
     * @return  a string that has the same contents as this string, but is
     *          guaranteed to be from a pool of unique strings.
     */
    public native String intern();复制代码

emmmm.... 실제로는 네이티브 메서드이지만 소스 코드를 볼 수 없더라도 상관은 없습니다. 주석에서 정보를 얻으세요:

intern 메소드를 호출할 때 문자열 상수 풀에 이미 String 개체와 동일한 문자열이 포함되어 있으면 문자열 상수 풀의 문자열에 대한 참조가 직접 반환됩니다. 그렇지 않으면 이 문자열 개체에 포함된 문자열이 상수 풀에 추가되고 이 개체에 대한 참조가 반환됩니다.

1.一个关于intern()的简单例子

了解了intern方法的用途之后,来看一个简单的列子:

public class Test {    public static void main(String[] args) {
        String str1 = "hello world";
        String str2 = new String("hello world");
        String str3=str2.intern();
        System.out.println("str1 == str2:"+(str1 == str2));
        System.out.println("str1 == str3:"+(str1 == str3));
    }
}复制代码

上面的一段代码会输出什么?编译运行之后如下:

과거를 복습하고 새로운 것을 배운다 (1) Java의 문자열에 대한 심층적 이해

如果理解了intern方法就很容易解释这个结果了,从上面截图中可以看到,我们的运行环境是JDK8。

String str1 = "hello world"; 这行代码会首先在Java堆中创建一个对象,并将该对象的引用放入字符串常量池中,str1指向常量池中的引用。

String str2 = new String("hello world");这行代码会通过new来实例化一个String对象,并将该对象的引用赋值给str2,然后检测字符串常量池中是否已经有了与“hello world”相等的对象,如果没有,则会在堆内存中再生成一个值为"hello world"的对象,并将其引用放入到字符串常量池中,否则,不会再去创建。这里,第一行代码其实已经在字符串常量池中保存了“hello world”字符串对象的引用,因此,第二行代码就不会再次向常量池中添加“hello world"的引用。

String str3=str2.intern(); 这行代码会首先去检测字符串常量池中是否已经包含了”hello world"的String对象,如果有则直接返回其引用。而在这里,str2.intern()其实刚好返回了第一行代码中生成的“hello world"对象。

因此【System.out.println("str1 == str3:"+(str1 == str3));】这行代码会输出true.

如果切到JDK6,其打印结果与上一致,至于原因读者可以自行分析。

과거를 복습하고 새로운 것을 배운다 (1) Java의 문자열에 대한 심층적 이해

2.改造例子,再看intern

上一节中我们通过一个例子认识了intern()方法的作用,接下来,我们对上述例子做一些修改:

public class Test {
    public static void main(String[] args) {
        String str1=new String("he")+new String("llo");
        String str2=str1.intern();
        String str3="hello";
        System.out.println("str1 == str2:"+(str1 == str2));
        System.out.println("str2 == str3:"+(str2 == str3)); 
    }
}复制代码

先别急着看下方答案,思考一下在JDK7(或JDK7之后)及JDK6上会输出什么结果?

1).JDK8的运行结果分析

我们先来看下我们先来看下JDK8的运行结果:

과거를 복습하고 새로운 것을 배운다 (1) Java의 문자열에 대한 심층적 이해

通过运行程序发现输出的两个结果都是true,这是为什么呢?我们通过一个图来分析:

과거를 복습하고 새로운 것을 배운다 (1) Java의 문자열에 대한 심층적 이해

String str1=new String("he")+new String("llo"); 这行代码中new String("he")和new String("llo")会在堆上生成四个对象,因为与本例无关,所以图上没有画出,new String("he")+new String("llo")通过”+“号拼接后最终会生成一个"hello"对象并赋值给str1。

String str2=str1.intern(); 这行代码会首先检测字符串常量池,发现此时还没有存在与”hello"相等的字符串对象的引用,而在检测堆内存时发现堆中已经有了“hello"对象,遂将堆中的”hello"对象的应用放入字符串常量池中。

String str3="hello"; 这行代码发现字符串常量池中已经存在了“hello"对象的引用,因此将str3指向了字符串常量池中的引用。

此时,我们发现str1、str2、str3指向了堆中的同一个”hello"对象,因此,就有了上边两个均为true的输出结果。

2).JDK6的运行结果分析

我们将运行环境切换到JDK6,来看下其输出结果:

과거를 복습하고 새로운 것을 배운다 (1) Java의 문자열에 대한 심층적 이해

有点意思!相同的代码在不同的JDK版本上输出结果竟然不相等。这是怎么回事呢?我们还通过一张图来分析:

과거를 복습하고 새로운 것을 배운다 (1) Java의 문자열에 대한 심층적 이해

String str1=new String("he")+new String("llo"); 这行代码会通过new String("he")和new String("llo")会分别在Java堆与字符串常量池中各生成两个String对象,由于与本例无关,所以并没有在图中画出。而new String("he")+new String("llo")通过“+”号拼接后最终会在Java堆上生成一个"hello"对象,并将其赋值给了str1。

String str2=str1.intern(); 这行代码检测到字符串常量池中还没有“hello"对象,因此将堆中的”hello“对象复制到了字符串常量池,并将其赋值给str2。

String str3="hello"; 这行代码检测到字符串常量池中已经有了”hello“对象,因此直接将str3指向了字符串常量池中的”hello“对象。 此时str1指向的是Java堆中的”hello“对象,而str2和str3均指向了字符串常量池中的对象。因此,有了上面的输出结果。

通过这两个例子,相信大家因该对String的intern()方法有了较深的认识。那么intern()方法具体在开发中有什么用呢?推荐大家可以看下美团技术团队的一篇文章《深入解析String#intern》中举的两个例子。限于篇幅,本文不再举例分析。

三、String类的结构及特性分析

前两节我们认识了String的内存分配以及它的intern()方法,这两节内容其实都是对String内存的分析。到目前为止,我们还并未认识String类的结构以及它的一些特性。那么本节内容我们就此来分析。先通过一段代码来大致了解一下String类的结构(代码取自jdk8):

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {        /** The value is used for character storage. */
        private final char value[];        /** Cache the hash code for the string */
         private int hash; // Default to 0
        //  ...}复制代码

可以看到String类实现了Serializable接口、Comparable接口以及CharSequence接口,意味着它可以被序列化,同时方便我们排序。另外,String类还被声明为了final类型,这意味着String类是不能被继承的。而在其内部维护了一个char数组,说明String是通过char数组来实现的,同时我们注意到这个char数组也被声明为了final,这也是我们常说的String是不可变的原因。

1.不同JDK版本之间String的差异

Java的设计团队一直在对String类进行优化,这就导致了不同jdk版本上String类的实现有些许差异,只是我们使用上并无感知。下图列出了jdk6-jdk9中String源码的一些变化。

과거를 복습하고 새로운 것을 배운다 (1) Java의 문자열에 대한 심층적 이해

可以看到在Java6之前String中维护了一个char 数组、一个偏移量 offset、一个字符数量 count以及一个哈希值 hash。 String对象是通过 offset 和 count 两个属性来定位 char[]  数组,获取字符串。这么做可以高效、快速地共享数组对象,同时节省内存空间,但这种方式很有可能会导致内存泄漏。

在Java7和Java8的版本中移除了 offset 和 count 两个变量了。这样的好处是String对象占用的内存稍微少了些,同时 String.substring 方法也不再共享 char[],从而解决了使用该方法可能导致的内存泄漏问题。

从Java9开始,String中的char数组被byte[]数组所替代。我们知道一个char类型占用两个字节,而byte占用一个字节。因此在存储单字节的String时,使用char数组会比byte数组少一个字节,但本质上并无任何差别。 另外,注意到在Java9的版本中多了一个coder,它是编码格式的标识,在计算字符串长度或者调用 indexOf() 函数时,需要根据这个字段,判断如何计算字符串长度。coder 属性默认有 0 和 1 两个值, 0 代表Latin-1(单字节编码),1 代表 UTF-16 编码。如果 String判断字符串只包含了 Latin-1,则 coder 属性值为 0 ,反之则为 1。

2.String字符串的裁剪、拼接等操作分析

在本节内容的开头我们已经知道了字符串的不可变性。那么为什么我们还可以使用String的substring方法进行裁剪,甚至可以直接使用”+“连接符进行字符串的拼接呢?

(1)String的substring实现

关于substring的实现,其实我们直接深入String的源码查看即可,源码如下:

    public String substring(int beginIndex) {            if (beginIndex < 0) {                throw new StringIndexOutOfBoundsException(beginIndex);
            }            int subLen = value.length - beginIndex;            if (subLen < 0) {                throw new StringIndexOutOfBoundsException(subLen);
            }            return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
        }复制代码

从这段代码中可以看出,其实字符串的裁剪是通过实例化了一个新的String对象来实现的。所以,如果在项目中存在大量的字符串裁剪的代码应尽量避免使用String,而是使用性能更好的StringBuilder或StringBuffer来处理。

(2)String的字符串拼接实现

1)字符串拼接方案性能对比

关于字符串的拼接有很多实现方法,在这里我们举三个例子来进行一个性能对比,分别如下:

使用”+“操作符拼接字符串

    public class Test {        private static final int COUNT=50000;        public static void main(String[] args) {
            String str="";            for(int i=0;i<COUNT;i++) {
                str=str+"abc";
            }
    }复制代码

使用String的concat()方法拼接

    public class Test {        private static final int COUNT=50000;        public static void main(String[] args) {
            String str="";            for(int i=0;i<COUNT;i++) {
                str=str+"abc";
            }
    }复制代码

使用StringBuilder的append方法拼接

    public class Test {        private static final int COUNT=50000;        public static void main(String[] args) {
            StringBuilder str=new StringBuilder();            for(int i=0;i<COUNT;i++) {
                str.append("abc");
            }
    }复制代码

如上代码,通过三种方法分别进行了50000次字符串拼接,每种方法分别运行了20次。统计耗时,得到以下表格:

拼接方法 最小用时(ms) 最大用时(ms) 平均用时(ms)
"+"操作符 4868 5146 4924
String的concat方法 2227 2456 2296
StringBuilder的append方法 4 12 6.6

从以上数据中可以很直观的看到”+“操作符的性能是最差的,平均用时达到了4924ms。其次是String的concat方法,平均用时也在2296ms。而表现最为优秀的是StringBuilder的append方法,它的平均用时竟然只有6.6ms。这也是为什么在开发中不建议使用”+“操作符进行字符串拼接的原因。

2)三种字符串拼接方案原理分析

”+“操作符的实现原理由于”+“操作符是由JVM来完成的,我么无法直接看到代码实现。不过Java为我们提供了一个javap的工具,可以帮助我们将Class文件进行一个反汇编,通过汇编指令,大致可以看出”+“操作符的实现原理。

    public class Test {        private static final int COUNT=50000;        public static void main(String[] args) {            for(int i=0;i<COUNT;i++) {
                str=str+"abc";
            }
    }复制代码

把上边这段代码编译后,执行javap,得到如下结果:

과거를 복습하고 새로운 것을 배운다 (1) Java의 문자열에 대한 심층적 이해

注意图中的”11:“行指令处实例化了一个StringBuilder,在"19:"行处调用了StringBuilder的append方法,并在第”27:"行处调用了String的toString()方法。可见,JVM在进行”+“字符串拼接时也是用了StringBuilder来实现的,但为什么与直接使用StringBuilder的差距那么大呢?其实,只要我们将上边代码转换成虚拟机优化后的代码一看便知:

    public class Test {        private static final int COUNT=50000;        public static void main(String[] args) {
            String str="";            for(int i=0;i<COUNT;i++) {
                str=new StringBuilder(str).append("abc").toString();
            }
    }复制代码

可见,优化后的代码虽然也是用的StringBuilder,但是StringBuilder却是在循环中实例化的,这就意味着循环了50000次,创建了50000个StringBuilder对象,并且调用了50000次toString()方法。怪不得用了这么长时间!!!

String的concat方法的实现原理关于concat方法可以直接到String内部查看其源码,如下:

public String concat(String str) {        int otherLen = str.length();        if (otherLen == 0) {            return this;
        }        int len = value.length;        char buf[] = Arrays.copyOf(value, len + otherLen);
        str.getChars(buf, len);        return new String(buf, true);
    }复制代码

可以看到,在concat方法中使用Arrays的copyOf进行了一次数组拷贝,接下来又通过getChars方法再次进行了数组拷贝,最后通过new实例化了String对象并返回。这也意味着每调用一次concat都会生成一个String对象,但相比”+“操作符却省去了toString方法。因此,其性能要比”+“操作符好上不少。

至于StringBuilder其实也没必要再去分析了,毕竟”+“操作符也是基于StringBuilder实现的,只不过拼接过程中”+“操作符创建了大量的对象。而StringBuilder拼接时仅仅创建了一个StringBuilder对象。

四、总结

本篇文章我们深入分析了String字符串的内存分配、intern()方法,以及String类的结构及特性。关于这块知识,网上的文章鱼龙混杂,甚至众说纷纭。笔者也是参考了大量的文章并结合自己的理解来做的分析。但是,避免不了的可能会出现理解偏差的问题,如果有,希望大家多多讨论给予指正。 同时,文章中多次提到StringBuilder,但限于文章篇幅,没能给出关于其详细分析。不过不用担心,我会在下一篇文章中再做探讨。 不管怎样,相信大家看完这篇文章后一定 对String有了更加深入的认识,尤其是了解String类的一些裁剪及拼接中可能造成的性能问题,在今后的开发中应该尽量避免。


위 내용은 과거를 복습하고 새로운 것을 배운다 (1) Java의 문자열에 대한 심층적 이해의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
이 기사는 juejin.im에서 복제됩니다. 침해가 있는 경우 admin@php.cn으로 문의하시기 바랍니다. 삭제