Skip to content

Latest commit

 

History

History
427 lines (317 loc) · 12.7 KB

数组和字符串的底层实现.md

File metadata and controls

427 lines (317 loc) · 12.7 KB

字符串常量池

也就是平常说的String Pool,但是在JVM中它对应的类是StringTable,底层实现是Hashtable,可以查看源码:

位置:hotspot/src/share/vm/classfile/symbolTable.hpp

class StringTable : public Hashtable<oop, mtSymbol> {
  ...
}

template <class T, MEMFLAGS F> class Hashtable : public BasicHashtableEntry<F> {
    ...
}

//CHeapObj前面分析过,也就是C堆
template <MEMFLAGS F> class BasicHashtableEntry : public CHeapObj<F> {
    ...
}

那么hashtable的key和value分别都是什么呢?

key

源码实现在symbolTable.cpp中:

oop StringTable::lookup(jchar* name, int len) {
  unsigned int hash = hash_string(name, len);
  int index = the_table()->hash_to_index(hash);
  return the_table()->lookup(index, name, len, hash);
}

通过代码可知:

1、通过String的内容和长度生成hash值,hash_string方法实现

// Pick hashing algorithm
unsigned int StringTable::hash_string(const jchar* s, int len) {
  return use_alternate_hashcode() ? AltHashing::murmur3_32(seed(), s, len) :
                                    java_lang_String::hash_code(s, len);
}

2、将hash值转换为index,也就是key。hash_to_index方法具体实现

  // Bucket handling
  int hash_to_index(unsigned int full_hash) {
    //hash值对hashtable长度取模,目的就是当hash值很大的时候能够在table中访问到
    int h = full_hash % _table_size;
    assert(h >= 0 && h < _table_size, "Illegal hash value");
    return h;
  }

value

在StringTable中有这样一个方法:

  oop basic_add(int index, Handle string_or_null, jchar* name, int len,
                unsigned int hashValue, TRAPS);

它用于添加元素,具体实现在symbolTable.cpp中,方法前面都是对添加的元素做一些判断,然后添加:

oop StringTable::basic_add(int index_arg, Handle string, jchar* name,
                           int len, unsigned int hashValue_arg, TRAPS) {

  assert(java_lang_String::equals(string(), name, len),
         "string must be properly initialized");
  // Cannot hit a safepoint in this function because the "this" pointer can move.
  No_Safepoint_Verifier nsv;

  // Check if the symbol table has been rehashed, if so, need to recalculate
  // the hash value and index before second lookup.
  //判断hashtable是否已经发生过rehashed,其实就是扩容,然后重新计算哈希值和索引
  unsigned int hashValue;
  int index;
  if (use_alternate_hashcode()) {
    hashValue = hash_string(name, len);
    index = hash_to_index(hashValue);
  } else {
    hashValue = hashValue_arg;
    index = index_arg;
  }

  // Since look-up was done lock-free, we need to check if another
  // thread beat us in the race to insert the symbol.

  oop test = lookup(index, name, len, hashValue); // calls lookup(u1*, int)
  if (test != NULL) {
    // Entry already added
    return test;
  }

  //核心实现
  HashtableEntry<oop, mtSymbol>* entry = new_entry(hashValue, string());
  add_entry(index, entry);
  return string();
}
new_entry

上面说过,hashValue也就是哈希值是通过String的内容和长度计算得到的,然后查看new_entry方法实现

template <class T, MEMFLAGS F> HashtableEntry<T, F>* Hashtable<T, F>::new_entry(unsigned int hashValue, T obj) {
  HashtableEntry<T, F>* entry;

  entry = (HashtableEntry<T, F>*)BasicHashtable<F>::new_entry(hashValue);
  entry->set_literal(obj);
  return entry;
}

继续点击new_entry方法,里面是具体构造一个entry的实现,这里就不贴代码了。

add_entry

add_entry(index, entry);的具体实现

template <MEMFLAGS F> inline void BasicHashtable<F>::add_entry(int index, BasicHashtableEntry<F>* entry) {
  //将entry的next指针指向前面的entry节点
  entry->set_next(bucket(index));
  _buckets[index].set_entry(entry);
  ++_number_of_entries;
}

//根据索引获取entry
template <MEMFLAGS F> inline BasicHashtableEntry<F>* BasicHashtable<F>::bucket(int i) {
  return _buckets[i].get_entry();
}

其实就是根据索引计算出这个entry的位置,然后插入,从代码可知是采用的是头插法

结论

根据上面的代码,可以知道value的生成方式:

将java的String类的实例(instanceOopDesc)封装成HashtableEntry

String.hashCode

在上面分析hashtable的key的时候,计算hash值有这么一段代码:

// Pick hashing algorithm
unsigned int StringTable::hash_string(const jchar* s, int len) {
  return use_alternate_hashcode() ? AltHashing::murmur3_32(seed(), s, len) :
                                    java_lang_String::hash_code(s, len);
}

注意到其中出现了java_lang_String::hash_code,也就是说,这个hash值是根据String的hashCode方法得到的,看下jdk中的hashCode具体实现:

    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

可以看到,它重写了object类的hashCode方法,并且计算结果与String的内容(char数组)是有关系的。

测试代码:

    public static void main(String[] args) {
        String str1="abc";
        String str2=new String("abc");
        System.out.println(str1.hashCode());
        System.out.println(str2.hashCode());
        //相同内容的hashcode是相同的,即使他们的引用不相同,前提是重写了object的hashcode方法
        //不然就算它们指向相同的数据,也不会相等,因为你hashcode是堆上对象产生的独特值
        System.out.println(str1.hashCode()==str2.hashCode());
    }

创建字符串在jvm中的存在形式

我们都知道,创建一个字符串有很多种方式,接下来就看看在不同的创建方式下它们在jvm中的存在形式。

双引号创建字符串对象

经过前面的学习,这幅图理解起来应该很轻松:字符串常量池就是一个hashtable结构,其中存放的就是将String实例封装成的HashtableEntry,所以其中的value指向的是String的内容,而String的底层就是字符数组char[](jdk9是byte[]数组,下面再具体分析),更确切的说,指向的是具体的值,也就是上图的"11"。

new String

和上面双引号创建对象不同的是,使用new会在堆上再创建一个String对象。而另外一个String是干什么的呢?为了将它封装成HashTableEntry存入常量池,这样以后当再需要引用这个字符串的时候,直接从字符串取就好了。比如有以下代码:

     public static void test2(){
1        String s1=new String("aa");
2        String s2=new String("aa");
         System.out.println(s1==s2);
     }

当执行完代码1后,内存情况

当执行完代码2后,内存情况

可以发现,当创建字符串对象s2时,因为常量池中已经有“aa"了,所以只会在堆上再创建一个对象。

堆栈情况如下

两个new String

每次new都会在堆上创建一个String对象,另外一个用于存入常量池中。

两个双引号创建

测试代码:

     public static void test1(){
1        String s1="11";
2        String s2="11";
         System.out.println(s1==s2);
     }

当执行完第1行后,会创建一个String和char[],图就不贴出来了。当执行完第2行后,不会创建String和char[]

拼接字符串底层实现

双引号+双引号

第一种情况

测试代码

    public static void test1(){
        String s1="11"+"11";
    }

这段代码会创建一个String对象和一个char[]对象,因为编译器做了优化,在编译的时候就将他们拼接在一起。字节码如下:

0 ldc #3 <1111>
2 astore_0
3 return

可以看到已经将“11”和“11”拼接成了“1111”

第二种情况

但是如果将代码改成这样:

     public static void test2(){
1        String s1="11";
2        String s2="11";
3        String s3=s1+s2;
     }

当执行完代码3后,会再创建一个String对象和char[]出来。字节码如下:

 0 ldc #4 <11>
 2 astore_0
 3 ldc #4 <11>
 5 astore_1
 6 new #5 <java/lang/StringBuilder>
 9 dup
10 invokespecial #6 <java/lang/StringBuilder.<init>> 
13 aload_0
14 invokevirtual #7 <java/lang/StringBuilder.append> #invokevirtual是调用实例方法,和invokespecial不同
17 aload_1
18 invokevirtual #7 <java/lang/StringBuilder.append>
21 invokevirtual #8 <java/lang/StringBuilder.toString>
24 astore_2
25 return

可以看到,在第6行这里,new了一个StringBuilder对象出来,然后调用append方法将字符串“11”和“11“拼接起来,最后调用toString方法,来看下StringBuilder的toString方法具体实现

    @Override
    public String toString() {
        // Create a copy, don't share the array
        return new String(value, 0, count);
    }

它创建的是一个副本,在String的三参构造函数中,是这么实现的

this.value = Arrays.copyOfRange(value, offset, offset+count);

这个方法需要特别注意,它是不会去访问常量池的,什么意思呢?看下面代码

    public static void test3(){
        String s1=new String(new char[]{'1','1'},0,2);
        String s2="11";
        System.out.println(s1==s2);
    }

输出结果是false,这时可以使用**s1.intern()**方法将它放入常量池中,结果为false。关于intern方法,后面在仔细讲解。

第三种情况
    public static void test4(){
        final String s1="1";
        final String s2="1";
        String s3=s1+s2;
        String s4="11";
        //System.out.println(s3==s4);
    }

输出结果为true,因为final修饰的字符串在编译的时候就拼接好了,这个叫常量替换。字节码如下:

 0 ldc #14 <1>
 2 astore_0
 3 ldc #14 <1>
 5 astore_1
 6 ldc #6 <11>
 8 astore_2
 9 ldc #6 <11>
11 astore_3
12 return

双引号+new String

测试代码:

    public static void test1() {
        String s1 = "11";
        String s2 = new String("11");
        String s3 = s1 + s2;
    }

执行完第三行代码会创建一个String和char[],字节码如下:

 0 ldc #3 <11>
 2 astore_0
 3 new #4 <java/lang/String>
 6 dup
 7 ldc #3 <11>
 9 invokespecial #5 <java/lang/String.<init>>
12 astore_1
13 new #6 <java/lang/StringBuilder>
16 dup
17 invokespecial #7 <java/lang/StringBuilder.<init>>
20 aload_0
21 invokevirtual #8 <java/lang/StringBuilder.append>
24 aload_1
25 invokevirtual #8 <java/lang/StringBuilder.append>
28 invokevirtual #9 <java/lang/StringBuilder.toString>
31 astore_2
32 return

Intern做了什么

上面已经演示过intern的用法了,说简单点就是字符串常量中没有就创建一个,然后返回引用,如果有就直接返回。下面具体分析下

需要注意的是在jdk6和jdk7中使用intern会返回不同的结果,在jdk6的时候,intern方法会将首次遇到的字符串实例实例复制到永久代的字符串常量池中存储,返回的当然也是永久代中这个字符串实例的引用,但是在jdk7的时候,已经将字符串常量池移到了堆中,那么只需要在常量池里记录一下首次出现的实例引用即可

String内部实现

字符串的存储在jdk8和jdk9的实现是不同的,jdk8中是一个char数组,而jdk9中换成了byte[]实现,这是为什么呢?

比如下面代码

public static void main(String[] args){
    //char占两个字节
    String s1="nihao"; //char[] 2*5=10 浪费5B 	byte[] 5*1=5
    String s2="你好"; //char[] 2*2=4	byte[] ?
}

可以看到,使用char[]的方式会浪费存储空间。使用byte[]来存的话,英文是没问题的,但是中文怎么办呢,因为中文占了两个字节。其实它还是会存储两个字节,然后在取的时候根据编码来区分,如果是s1这种,那么就按一个字节来取,如果是s2这种,那么就按两个字节来取。所以在jdk9的String类中加了一个属性

private final byte coder;

jdk9字符串去重

底层原理:用==比较不是同一个字符串,但内容是相等的,会去重 。可通过参数开启,其中还涉及到了GC,关于这个具体可以查看这篇文章:https://zhuanlan.zhihu.com/p/29324764