文章目录
1. String类型为什么不可变?
- String类被final修饰导致其不能被继承,进而避免了子类破坏String不可变
- 保存字符串的数组被final修饰且为私有的,并且String类没有提供/暴露修改这个字符串的方法
- String是不可变,关键是因为SUN公司的工程师,在后面所有String的方法里很小心的没有去动Array里的元素,没有暴露内部成员字段。private final char value[]这一句里,private的私有访问权限的作用都比final大。而且设计师还很小心地把整个String设成final禁止继承,避免被其他人继承后破坏。所以**String是不可变的关键都在底层的实现,而不是一个final。**考验的是工程师构造数据类型,封装数据的功力。
- 不可变有什么好处?
- 这个最简单地原因,就是为了安全。看下面这个场景,一个函数appendStr( )在不可变的String参数后面加上一段“bbb”后返回。appendSb( )负责在可变的StringBuilder后面加“bbb”。
class Test{
//不可变的String
public static String appendStr(String s){
s+="bbb";
return s;
}
//可变的StringBuilder
public static StringBuilder appendSb(StringBuilder sb){
return sb.append("bbb");
}
public static void main(String[] args){
//String做参数
String s=new String("aaa");
String ns=Test.appendStr(s);
System.out.println("String aaa >>> "+s.toString());
//StringBuilder做参数
StringBuilder sb=new StringBuilder("aaa");
StringBuilder nsb=Test.appendSb(sb);
System.out.println("StringBuilder aaa >>> "+sb.toString());
}
}
//Output:
//String aaa >>> aaa
//StringBuilder aaa >>> aaabbb
-
如果程序员不小心像上面例子里,直接在传进来的参数上加"bbb",因为Java对象参数传的是引用,所以可变的的StringBuffer参数就被改变了。可以看到变量sb在Test.appendSb(sb)操作之后,就变成了"aaabbb"。有的时候这可能不是程序员的本意。所以String不可变的安全性就体现在这里。
-
再看下面这个HashSet用StringBuilder做元素的场景,问题就更严重了,而且更隐蔽。
class Test{
public static void main(String[] args){
HashSet<StringBuilder> hs=new HashSet<StringBuilder>();
StringBuilder sb1=new StringBuilder("aaa");
StringBuilder sb2=new StringBuilder("aaabbb");
hs.add(sb1);
hs.add(sb2); //这时候HashSet里是{"aaa","aaabbb"}
StringBuilder sb3=sb1;
sb3.append("bbb"); //这时候HashSet里是{"aaabbb","aaabbb"}
System.out.println(hs);
}
}
//Output:
//[aaabbb, aaabbb]
-
StringBuilder型变量sb1和sb2分别指向了堆内的字面量"aaa"和"aaabbb"。把他们都插入一个HashSet。到这一步没问题。但如果后面我把变量sb3也指向sb1的地址,再改变sb3的值,因为StringBuilder没有不可变性的保护,sb3直接在原先"aaa"的地址上改。导致sb1的值也变了。这时候,HashSet上就出现了两个相等的键值"aaabbb"。破坏了HashSet键值的唯一性。所以千万不要用可变类型做HashMap和HashSet键值。
-
还有一个大家都知道,就是在并发场景下,多个线程同时读一个资源,是不会引发竟态条件的。只有对资源做写操作才有危险。不可变对象不能被写,所以线程安全。
-
最后别忘了String另外一个字符串常量池的属性。像下面这样字符串one和two都用字面量"something"赋值。它们其实都指向同一个内存地址。
String one = "someString";
String two = "someString";

- 这样在大量使用字符串的情况下,可以节省内存空间,提高效率。但之所以能实现这个特性,String的不可变性是最基本的一个必要条件。要是内存里字符串内容能改来改去,这么做就完全没有意义了。
2. 如果自定义一个String类,类加载顺序是怎么样的?
1. 情况一:自定义String类的名称为java.lang.String
-
假如我们自己写了一个java.lang.String的类,我们是否可以替换调JDK本身的类?
- 不能实现
- 为什么?
- 网上解释是说双亲委托机制解决这个问题,其实不是非常的准确。因为双亲委托机制是可以打破的,你完全可以自己写一个classLoader来加载自己写的java.lang.String类,但是你会发现也不会加载成功,具体就是因为*针对java.开头的类,jvm的实现中已经保证了必须由bootstrp来加载。
- 因加载某个类时,优先使用父类加载器加载需要使用的类。如果我们自定义了java.lang.String这个类,加载该自定义的String类,该自定义String类使用的加载器是App ClassLoader,根据优先使用父类加载器原理,App ClassLoader加载器的父类为Extension ClassLoader,所以这时加载String使用的类加载器是Extension ClassLoader,但是类加载器Extension ClassLoader在jre/lib/ext目录下没有找到String.class类。然后使用Extension ClassLoader父类的加载器Bootstrap ClassLoader,父类加载器Bootstrap ClassLoader在JRE/lib目录的rt.jar找到了String.class,将其加载到内存中。这就是类加载器的委托机制。
-
什么是双亲委派模型?
- 类加载器可分为两类:一是启动类加载器(Bootstrap ClassLoader),是C++实现的,是JVM的一部分;另一种是其它的类加载器,是Java实现的,独立于JVM,全部都继承自抽象类java.lang.ClassLoader。jdk自带了三种类加载器,分别是启动类加载器(Bootstrap ClassLoader),扩展类加载器(Extension ClassLoader),应用程序类加载器(Application ClassLoader)。后两种加载器是继承自抽象类java.lang.ClassLoader。
- 一般是: 自定义类加载器 >> 应用程序类加载器 >> 扩展类加载器 >> 启动类加载器。这种层次关系被称为双亲委派模型(Parents Delegation Model)。除了最顶层的启动类加载器外,其余的类加载器都有对应的父类加载器。
- 再简单说下双亲委托机制:如果一个类加载器收到了类加载的请求,它首先不会自己尝试去加载这个类,而是把这个请求委派给父类加载器,每一个层次的类加载器都是加此,因此所有的加载请求最终到达顶层的启动类加载器,只有当父类加载器反馈自己无法完成加载请求时(指它的搜索范围没有找到所需的类),子类加载器才会尝试自己去加载。各个类加载器之间是组合关系,并非继承关系。
-
为什么要使用双亲委派模型?
- 双亲委派模型可以确保安全性,可以保证所有的java类库都是由启动类加载器加载。如用户编写的java.lang.Object,加载请求传递到启动类加载器,启动类加载的是系统中的Object对象,而用户编写的java.lang.Object不会被加载。如用户编写的java.lang.virus类,加载请求传递到启动类加载器,启动类加载器发现virus类并不是核心java类,无法进行加载,将会由具体的子类加载器进行加载,而经过不同加载器进行加载的类是无法访问彼此的。由不同加载器加载的类处于不同的运行时包。所有的访问权限都是基于同一个运行时包而言的。
- 双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。
-
如何打破双亲委派模型
- 自定义加载器的话,需要继承
ClassLoader
。如果我们不想打破双亲委派模型,就重写ClassLoader
类中的findClass()
方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写loadClass()
方法。 - 为什么是重写
loadClass()
方法打破双亲委派模型呢?- 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器
loadClass()
方法来加载类) - 重写
loadClass()
方法之后,我们就可以改变传统双亲委派模型的执行流程。例如,子类加载器可以在委派给父类加载器之前,先自己尝试加载这个类,或者在父类加载器返回之后,再尝试从其他地方加载这个类。具体的规则由我们自己实现,根据项目需求定制化。 - 我们比较熟悉的 Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器
WebAppClassLoader
来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理。
- 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器
- 自定义加载器的话,需要继承
2. 情况二:自定义String类包名不是java.lang
- 自定义String和java自带的String不在同一个包下是可以加载的,自定义了自己的类加载器,去加载自定义String类
3. String s1 = new String(“abc”);这句话创建了几个字符串对象?
- 会创建 1 或 2 个字符串对象
- 当JVM遇到上述代码时,会先检索字符串常量池中是否存在“abc”,如果不存在“abc”这个字符串,则会先在常量池中创建这个一个字符串。然后再执行new操作,会在堆内存中创建一个存储“abc”的String对象,对象的引用赋值给s1。此过程创建了2个对象。
- 如果检索字符串常量池时发现已经存在了对应的字符串,那么只会在堆内创建一个新的String对象,此过程只创建了1个对象。
4. String、StringBuffer、StringBuilder的区别?
1. 可变性
-
String
是不可变的。 -
StringBuilder
与StringBuffer
都继承自AbstractStringBuilder
类,在AbstractStringBuilder
中也是使用字符数组保存字符串,不过没有使用final
和private
关键字修饰,最关键的是这个AbstractStringBuilder
类还提供了很多修改字符串的方法比如append
方法。
2. 线程安全性
-
String
中的对象是不可变的,也就可以理解为常量,线程安全。 -
AbstractStringBuilder
是StringBuilder
与StringBuffer
的公共父类,定义了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共方法。StringBuffer
对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder
并没有对方法进行加同步锁,所以是非线程安全的。
3. 性能
- 每次对
String
类型进行改变的时候,都会生成一个新的String
对象,然后将指针指向新的String
对象。 StringBuffer
每次都会对StringBuffer
对象本身进行操作,而不是生成新的对象并改变对象引用。- 相同情况下使用
StringBuilder
相比使用StringBuffer
仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。
4. 三者使用总结
- 操作少量的数据: 适用
String
- 单线程操作字符串缓冲区下操作大量数据: 适用
StringBuilder
- 多线程操作字符串缓冲区下操作大量数据: 适用
StringBuffer
5. String 和 JVM有什么关系?
- 字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
- String 常见的创建方式有两种,直接赋值的方式和new String() 的方式
- 直接赋值的方式会先去字符串常量池中查找是否已经有此值,如果有则把引用地址直接指向此值,否则会先在常量池中创建,然后再把引用指向此值
- new String() 的方式一定会先在堆上创建一个字符串对象,然后再去常量池中查询此字符串的值是否已经存在,如果不存在会先在常量池中创建此字符串,然后把引用的值指向此字符串。
- JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。
- JDK 1.7 为什么要将字符串常量池移动到堆中?
- 因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。
6. String有没有长度限制?是多少?为什么?
- 首先字符串的内容是由一个字符数组 char[] 来存储的,由于数组的长度及索引是整数,且String类中返回字符串长度的方法length() 的返回值也是int ,所以通过查看java源码中的类Integer我们可以看到Integer的最大范围是2^31 -1,由于数组是从0开始的,所以**数组的最大长度可以使【0~2^31】**通过计算是大概4GB。
- 但是通过翻阅java虚拟机手册对class文件格式的定义以及常量池中对String类型的结构体定义我们可以知道对于索引定义了u2,就是无符号占2个字节,2个字节可以表示的最大范围是2^16 -1 = 65535。
- 但是由于JVM需要1个字节表示结束指令,所以这个范围就为65534了。超出这个范围在编译时期是会报错的,但是运行时拼接或者赋值的话范围是在整形的最大范围。
7. String中的intern方法
- 在JDK7之前的版本,调用这个方法的时候,会去常量池中查看是否已经存在这个常量了,如果已经存在,那么直接返回这个常量在常量池中的地址值,如果不存在,则在常量池中创建一个,并返回其地址值。
- 但是在JDK7以及之后的版本中,常量池从perm区搬到了heap区。intern检测到这个常量在常量池中不存在的时候,不会直接在常量池中创建该对象了,而是将堆中的这个对象的引用直接存到常量池中,减少内存开销。
参考链接
- 如何理解 String 类型值的不可变? - 胖君的回答 - 知乎
https://www.zhihu.com/question/20618891/answer/114125846 - https://blog.youkuaiyun.com/qq_23000805/article/details/89703632
- https://www.nowcoder.com/share/jump/1695015150039
- https://javaguide.cn/java/jvm/classloader.html
- https://blog.youkuaiyun.com/bingxuesiyang/article/details/90053387
- http://t.csdn.cn/NGbG9