java高级——String字符串探索(在jvm底层中如何实现,常量池中怎么查看)
- 文章介绍
- 提前了解的知识点
- 1. 常量池
- 2. Jvm虚拟机
- 3. 字节码
- String类详解
- 1. String对象在申明后将不可修改,是不可变类
- 2. String进行相加相减等操作时一定会创建新的对象
- 3. new String(“abc”)和直接 “abc”的区别
- 4. String类常用的方法
- 5. String中format方法(重点)
- 6. String.join()方法
- 7. StringBuilder和StringBuffer(重点)
文章介绍
此文为java高级系列的第一篇,探索String字符串,包括但不限于字符串在jvm中如何存储和操作
、直接定义字符串和new String
的区别、以及常量池
的知识点。
提前了解的知识点
1. 常量池
参考:常量池详解
常量池是java中比较重要的一个概念,作用是为了加快整个系统的性能
,它的存在我们可以理解为内存复用
,也就是享元模式
的概念。简单来说就是这已经有一个椅子了,再有一个不是占地方嘛,我们可以共享这个椅子。
常量池可分为三大类,目前我们只研究字符串常量池
。
- class文件常量池;
- 字符串常量池;
- 运行时常量池;
如何知道一个类中的常量池有哪些东西呢,可以用javap -verbose 类名
来查看。
2. Jvm虚拟机
jvm又叫做虚拟机
,也就是一个拟态的计算机,我们写完代码之后怎么运行是不管的,这个操作就是jvm来完成的,java文件最终会编译为字节码
文件,而jvm执行的就是字节码文件,最终运行出我们想要的结果。(就当做是一个胃,吃完饭谁还管它怎么消化啊)
jvm强大之处在于它能跨平台
运行,也就是说无论是什么语言,只要能变成字节码文件(也就是class文件),jvm都能运行,这才是java能一直这么流行的核心所在。
3. 字节码
字节码(Byte-code)是一种包含执行程序
,由一序列 op 代码/数据对
组成的二进制文件
,是一种中间码
。
上面可能有些抽象,学过编译原理的对二进制都不陌生,各种1001的数字,计算机最终识别的就是这玩意儿,本文只是会用到这方面的知识,并不深究,我们目前只需要知道,字节码文件是jvm识别并运行的,它所记录的内容很详细,详细到每一步执行了什么。
参考:字节码详解
String类详解
1. String对象在申明后将不可修改,是不可变类
首先第一点,不可变类
是什么意思?
很简单,String是不可被继承的类,因为是被final修饰的,也就是一个顶级对象了。
第二点,申明后不可修改是什么意思?虽然我们在编程中经常对一个字符串进行拼接或者更改内容的操作,但实际对象是一直在变化的,如下:
public class Test {
public static void main(String[] args) {
String a = "Hello";
a = "World";
}
}
上面的代码创建了几个对象呢?大多数人都会认为是1个,我就定义了一个对象啊,但实际在底层,它创建了两个对象,这对应了上面所说的,String申明后不可修改,接下来用字节码验证。
首先执行这段代码,找到编译后的class文件位置,在class文件位置下打开cmd;
之后运行反编译代码查看常量池:
D:\env\IDEA\Java-study\string-01\target\classes\com\lgt>javap -verbose Test.class
// Classfile属性为你的字节码文件绝对地址
Classfile /D:/env/IDEA/Java-study/string-01/target/classes/com/lgt/Test.class
// 最后修订时间以及文件大小
Last modified 2024年5月19日; size 442 bytes
// SHA加密哈希值,代表文件唯一标识,也有可能是md5加密(我是jdk17,之前可能使用md5)
SHA-256 checksum 1a2c5dc3f8ce4ae72046ade88aad0cdb5dc8ed1d0f1f1d6ba725605acfd9a0e0
// 源文件名称
Compiled from "Test.java"
public class com.lgt.Test
// 副版本号
minor version: 0
// 主版本号
major version: 52
// 访问标识,标明你这是类还是接口,用什么修饰符修饰的,比如public还是private
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
// 类的相对路径,#11表示这是常量池中索引为11的值
this_class: #11 // com/lgt/Test
// 父类的相对路径,可以看到父类是Object
super_class: #2 // java/lang/Object
// 分别代表接口数量、静态或实例变量数量、方法数量、属性数量
interfaces: 0, fields: 0, methods: 2, attributes: 1
// 重点:常量池-----------------------------------------------------------
// #数字代表常量的索引值,从1开始
// =后跟常量的访问标识
// 最后是常量的值
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = String #8 // Hello
#8 = Utf8 Hello
#9 = String #10 // World
#10 = Utf8 World
#11 = Class #12 // com/lgt/Test
#12 = Utf8 com/lgt/Test
#13 = Utf8 Code
#14 = Utf8 LineNumberTable
#15 = Utf8 LocalVariableTable
#16 = Utf8 this
#17 = Utf8 Lcom/lgt/Test;
#18 = Utf8 main
#19 = Utf8 ([Ljava/lang/String;)V
#20 = Utf8 args
#21 = Utf8 [Ljava/lang/String;
#22 = Utf8 a
#23 = Utf8 Ljava/lang/String;
#24 = Utf8 SourceFile
#25 = Utf8 Test.java
// 下面是类中每个方法的描述,下面是构造方法
{
public com.lgt.Test();
// 方法描述:形参列表和返回值,v代表没有返回值
descriptor: ()V
// 方法标识:public方法
flags: (0x0001) ACC_PUBLIC
// 方法code属性
Code:
// stack:操作数栈最大数量
// locals:局部变量表长度,上面我们就定义了一个变量
// args_size:方法接收参数长度
stack=1, locals=1, args_size=1
// 下面的数字表示偏移量,冒号后面为操作码,最后为操作树
// aload表示字节码指令,意思为压栈操作
0: aload_0
// 表示执行方法,#1代表常量池中Object的init初始化方法
1: invokespecial #1 // Method java/lang/Object."<init>":()V
// 结束操作
4: return
// 行号表:字节码指令的偏移量和java源代码中的行号一一对应关系
LineNumberTable:
// line9表示java文件中第九行,0对应上面的偏移量0
line 9: 0
// 局部变量表:介绍了方法中所有的参数信息
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/lgt/Test;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=1
0: ldc #7 // String Hello
2: astore_1
3: ldc #9 // String World
5: astore_1
6: return
LineNumberTable:
line 11: 0
line 12: 3
line 13: 6
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 args [Ljava/lang/String;
3 4 1 a Ljava/lang/String;
}
SourceFile: "Test.java"
参考:
字节码指令详解
JAVA字节码文件之第三篇(访问标识)
javap命令详解
Java字节码常量池深入剖析
上面为字节码文件详解,包含了常用的大多数属性解释,本文已经足够,如果要完全理解上面说的String创建两个对象的问题,还是得仔细看一下上面这段解释。
上图表明底层是创建了两个对象,一个是Hello,一个是World,但有人就纳闷了,我明明就只有一个对象a呀,哪来的两个。注意了,我们现在探讨的是jvm的底层哦,下图确实说明只有一个变量a,但那是一个字面量
,也就是一个key而已,两行代码分别对应了两个对象的地址。
这里大家记着一句话,非常好使:
只要是使用双引号赋值的字符串
,那么在编译的时候,java会将其放在方法区中的字符串常量池
中,在放入前会进行检查,如果有内容相同的则返回地址,这就是享元模式的雏形。
2. String进行相加相减等操作时一定会创建新的对象
java中字符串操作大致分为两类,一类是编译时
操作,也就是用双引号赋值的字符串其实在编译时就执行并放入了常量池
,一类则是运行时
操作,也就是在程序运行时
产生的字符串,这种字符串最后也会放入常量池,只不过执行步骤不一样。
一般没有完全使用双引号定义的字符串,都在运行时才会产生,就是我们对字符串进行加减或者拼接分割操作
等,这时候java会先在堆中创建一个对象
,之后再去常量池中寻找
,如果有则直接拿来相应的地址,但是,这已经在堆中存在了一个对象
,这也就是为什么一定会创建新对象的原因。
为什么要说这个呢,看一个例子大家就明白了。
我们对a变量进行了截取操作,按照以往的理解,a对象已经被更改了,a应该也输出World,但实际并没有,这也是我们最开始常见的一个错误,一定要将操作后的字符串重新赋值给a才算完事儿,不知道你有没有犯过这个错误呢?
3. new String(“abc”)和直接 “abc”的区别
如果对上面的例子明白了之后就会很好理解这两个的区别。
直接用双引号赋值的字符串会在编译时放入常量池,而new String一定会先在堆中存放一个对象,后面去常量池寻找对应的字符串。
看一下上面这个例子,一般只有纯双引号操作的字符串,最后才相等,其它操作基本都会重新创建字符串,而这里要介绍一个特殊的方法,intern()
;这个方法的原理是先在字符串常量池找内容相同的字符串,如果有则返回地址,没有则在常量池放入,返回新地址,所以比较的结果是true。
4. String类常用的方法
- endsWith:判断字符串是否以指定的后缀结束
- startsWith,判断字符串是否以指定的前缀开始
equals
,字符串相等比较,不忽略大小写equalsIgnoreCase
,字符串相等比较,忽略大小写indexOf
,取得指定字符在字符串的位置- lastIndexOf,返回最后一次字符串出现的位置
- length,取得字符串的长度
replaceAll
,替换字符串中指定的内容,注意赋值原字符串split
,根据指定的表达式拆分字符串substring
,截子串- trim,去前尾空格
- valueOf,将其他类型转换成字符串
- contains,检测字符串包含其它字符串
5. String中format方法(重点)
这是String类中最为强大的方法之一,能对字符串进行多种复杂和高级操作,比如补0操作,日期格式化,进制转换都能做,不要在操作数字的时候,如果有在数字前后补0操作时直接拼接字符串啦。
参考:JAVA字符串格式化-String.format()的使用
6. String.join()方法
这个方法是用指定的分割符将一串字符拼接起来
,比如将123拼接位1-2-3这种,使用起来也很简单。
String join = String.join("-", "1", "2", "3");
// join = 1-2-3
System.out.println("join = " + join);
为什么要单独把这个方法拎出来呢,首先这是一个平常被大家忽略的方法,其实在某些指定场景下,这个方法使用起来要更加的方便高效。从底层代码来看,join方法会先创建一个固定大小的String数组for循环进行拼接
,所以效率相对于下面两个方法稍微快一些。
但它的局限性也比较大,不适合在循环中操作
,同时在比较复杂的场景也不适合,因为每次操作都返回一个新的字符串,需要不断地赋值
,体验稍差,最需要注意的一点是,大多数jdk版本都不会识别null的情况
,如果是null不会报错,但会把null打印出来。
7. StringBuilder和StringBuffer(重点)
这两个方法介绍起来也简单,首先记住一句话,多次操作不会创建新的对象
!
这个就是说你不断拼接字符串的过程中,对象始终是一个,不会在创建新的对象
,这对于操作大的字符串非常有效,同时对于有些要再循环中拼接或者需要复杂判断得出的字符串非常有用。
不同点:
StringBuilder | StringBuffer |
---|---|
线程不安全 | 线程安全 |
效率较快 | 效率相对较低 |
其实大多数人堆线程安全不安全的概念还不是很清楚,所谓线程安全就是在操作时需要排队
,一个人操作完后另一个人才能继续操作,StringBuffer之所以相对较慢是因为底层的方法都有synchronized 关键字修饰,安全了效率肯定相对较慢啦。
如何选择这两个方法,大多数我们使用的都是StringBuilder
,因为效率较快,如果你的系统是薪酬或者医院或者管理系统
,建议使用StringBuffer,这些系统都是安全第一的。
简单说说为什么会有线程不安全的问题,首先这两个类都继承的是AbstractStringBuilder
,虽然两者在实现上有些许不同,StringBuffer 每次获取 toString 都会直接使用缓存区的 toStringCache 值来构造一个字符串
,而 StringBuilder 则每次都需要复制一次字符数组
,再构造一个字符串。但是最终都是在AbstractStringBuilder进行字符串的处理,java的源码大多数都喜欢将一些对象定义为全局变量
,不会创建新的对象,只是不断地在改变变量的值
,这是一种优化效率的常见手段,如果线程并发过多,有时候就可能出现内容不正确的问题,就是A线程给value赋值aaa正准备拼接,结果B线程正好将value值改为了bbb,那A线程中就出现了字符bbb。这也是StringBuffer的方法有synchronized 关键字的原因。
这次对于String的探索就到这里,之后还会对java中多个重要知识点进行研究深入,比如抽象类、集合、map、读写流、Stream等等,如果文章中有什么不对的地方还请大家指出共同探索。