java基础复习(二):深入理解 java.lang.String以及对线程安全问题的思考

如何理解String

我们提到string,总是离不开“字面量”和“对象”。那么string到底底层是一个什么?string底层就是一个字符数组。如果学过C语言,肯定知道:C语言的字符数组和字符串联系非常密切。C语言通过使用’/0’标志字符串的结尾,如果而string的长度就是字符数组的长度减去尾部的’/0’。现在让你实现一个字符串,底层无非是两种组织结构,一种是链表,一种是数组。
链表可以做到真正的无界(不考虑机器自身的瓶颈),而数组想要做到“非固定大小”就需要在必要的时候通过系统调用,申请新的内存空间。不过字符串作为一个对“访问性能”要求很高的数据结构,我更青睐于使用数组实现,毕竟我可不想查询一个中间字符还需要从头遍历一遍——数组随机访问的特性使得访问字符串中某一个字符成员效率很高。当然了,不排除某些产品要求字符串频繁修改而基于链表实现字符串,说白了,如何实现还是看具体的应用场景。
那么,我们现在就使用数组作为string的底层结构吧。假设现在摆在面前的是C数组,没有所谓的属性,就是一个指向内存块的首地址的指针罢了。而String本质上就是对数组的进一步封装,我们封装的结果:提供了属性len,将访问数组长度的时间复杂度从O(N)降低为了O(1),然后我们提供了各种操纵底层数组的方法,我们还可以提供一些扩容和缩容的策略。如此,我们就成功基于字符数组实现了一个名为string的结构体。这个结构体可以看作一个动态字符串。

总结:String是一个字符串的类型,它是对底层字符数组的进一步封装,并且提供了访问和修改字符串对象的方法。
(string是一个类,它提供了很多实例方法,当一个字符串对象被创建后,这些实例方法就是操作当前对象(this)的方法)

不可变String

java的string为我们提供的字符串是静态的、不可变的。
如何理解?你创建了一个字符串,然后你修改了它,此时你好像觉得修改了是同一个字符串对象。其实底层返回的一个新的字符串对象,而当前引用也从旧对象重新指向了新的对象。不过字面量通常不会被回收(这个后面会讲)

我想很多人都被问到过:string如何实现不可变。其实这个很容易理解
【1】最容易想到的——如果用户能够直接通过 对象.属性 获取底层数组,那么肯定能修改啊。因此,底层数组必须是私有的
【2】如果我继承了string,然后重写它某个方法去间接打破不可变怎么办?因此,string是私有的
【3】很多人都会提到final修饰char[],但是这点其实比不过第一点,因为char[]是一个引用类型,是一个指针,final修饰它仅能保证一个“数组对象本身如何改变,char[]指针都一辈子指向它一个对象”,也就是说不能保证数组对象本身不会改变。也可以作为一点,如果一个string对象出生之后就仅仅指向唯一一个数组对象,那么也算是保证内部的数组指针不可变
【4】最重要的一点:严谨的访问控制。设计string的程序员当碰到修改操作时总是返回一个新创建的对象,而不是返回一个指针。这从根本上保证了用户得到的一定是新对象,也保证了底层数组仅仅能被用于访问。一旦涉及到修改操作,那么它的生命基本也走到了尽头(当前使用价值没了,等待下一个用户重新从字符串常量池中获取)

如果问你如何设计不可变类,那么string将是一个很好的参考:不可继承、底层数据结构私有化、严密的访问控制、涉及到对象本身的方法返回值总是一个(深)拷贝对象

String的常量池

java的string是不可变的,这意味着一旦发生修改操作,或者返回值是一个string对象,总是需要返回一个新建的对象。string对象本质上变为了一个只读对象
创建一个对象是需要内存空间、初始化等一系列操作的,而销毁一个对象也不是说销毁就销毁的,GC是不可预知的,如果不停的丢弃对象(不再指向)而这些对象又没有被GC及时回收,那么这些对象就会造成内存泄露

java提出了一种优化思路:
string作为一个经常被使用的、不可变的对象。我能不能在用户正式使用前(如String类加载阶段)先创建出一些对象,然后将这些对象的指针保存在一个哈希表中,当用户需要用,我就把这个指针给它。它用完(这个string对象)之后把指针给了别人(别的string指针/引用),那么这个对象由于被哈希表强引用着,它并不会被GC回收掉,一旦有其他方法想要接着使用这个对象,那么再把这个指针给他。
上面说的对象就是存放在堆中的字符串对象,而指针指的就是双引号代表的字面量

反编译:访问字面量

记住:字面量就是对象的引用,是一个引用类型。
【严格来说,字面量指向引用,引用又指向对象】

        String s ="42";

核心的jvm指令有两个
【1】idc
【2】astore
astore就是将引用类型(字面量卸下它的面具,就是一个引用)放入局部变量表的slot。而idc就是从常量池中取出“42”。编译后,每个字面量和double、long一样,会被作为一个结构体放入常量池中,而这个结构体就是constant_String_info,它会执行另一个结构体constant_utf8_info,这个结构体以字节数组的形式保存了“42”这个字面量(或者说是一种符号引用,而这个符号引用最终会转换为一个直接引用,通过这个直接引用可以访问到堆中的string对象)

字面量的解析

在jvm源码中(C语言),保存字符串字面量的结构是类似哈希表的stringTable,因此一旦存入过多引用也会导致性能下降(哈希冲突),因为这个stringTable是不能扩容的。和其他基本类型的字面量不同,字符串字面量并不会在类加载的解析阶段就被转换被直接引用,而是懒解析的。当类加载完成后,字符串字面量仍然作为符号引用被存入运行时常量池,此时stringTable也并没有相应的引用,堆中也必然没有对象。
当第一次调用idc指令,试图获取引用失败后,jvm在堆中创建一个对象,并且将引用存储在stringTable并返回这个引用(字面量)

字符串常量池

stringTable在jvm中体现的结构被称为字符串常量池。1.6中字符串常量池属于运行时常量池的一部分(运行时常量池是方法区/永久代一部分),每个class实例都有一个。而1.7时字符串常量池被移入堆中,每个VM实例拥有一个,多个class实例共用一个。
字符串常量中要存放哪些引用一般都是在程序编译后就确认了(在池中的可能是直接引用或符号引用,一部分属于懒解析,所以没有被解析为直接引用,stringTable也没有引用…),但是也可以通过intern()方法,在程序运行时向池中添加引用

其实调用intern也是触发idc的一种方式,当一个字符串调用intern方法时,如果stringTable没有该对象的引用,则会添加该引用到stringTable中去,并返回这个引用。如果存在直接返回对应引用。intern()是一个native方法,底层就是一个往stringTable添加引用的操作(类似map.put())。而1.6和1.7的区别就是当stringTable找不到引用时,放入的哪一个对象的引用

深度分析经典问题:new String () 和 intern()

        String s = new String("a") + new String("b");
        s.intern();
        System.out.println(s=="ab");//true  1.8true  1.7false

当程序被编译后,“a”和“b”和“ab”肯定都是进入池中了。但是s.intern()先执行,此时因为“ab”还没有调用过idc,所以stringTable没有引用,s.intern()直接将s对应对象的引用s放入stringTable,当使用“ab”会再次触发idc,这时拿到的就是原来的s。
总之:一定要把字面量看作引用,而不是对象!!!(字面量可以看作key,而真正的引用是value,引用的值就是堆中对象的内存首地址)

注意:对应上面这个程序
【1】1.6时,如果s.intern()发现字符串常量池中没有“ab”这个引用,会向创建对象,并将“ab”存储在stringTable,然后返回这个“ab”引用。(也就是说,一旦没有就创建,即使现在已经有了一个现成的s)。因此当比较“ab”与s时返回false
【2】1.7时,则发现没有这个“ab”后,不会创建对象,而是直接将s存入stringTable。当比较时,s就是“ab”

1.7及以后,如果上面的程序返回false,只有一个可能:“ab”这个引用在此之前被访问过,也就是说idc不是第一次触发

如果访问“ab”比s.intern()先执行,那么s.intern()(除了返回“ab”面具之下引用)就没有任何意义了,最终结果返回false

String s = new String("1");
s.intern(); 
String s2 = "1";
System.out.println(s == s2);//false

第一句中访问了“1”,此时idc已经被首次触发,当再次调用intern()就毫无意义了

        String b="abc"; 
        String c="abc";

这两句的意义:第一句触发idc,在堆中创建对象,然后“abc”指向对象并存储在stringTable。第二句无法就是将指针b也执行堆中的那个string对象

        String d = new String("AA");

这一句:先触发idc,堆中创建对象、保存引用。然后又在堆中创建了一个对象,并将指针d指向该对象。(共创建两个对象)

+号

        String a ="aa"+"a";
        String b1 ="bb";String b2 ="bbb";
        String b ="b"+b1+b2+"bbbbb";

如果涉及加号的表达式全部使用字面量,则在编译期间进行优化,消除加号,并且拼接为“aaa”,也就是说编译后的产物是“aaa”,而“a”和“aa”并没有放入池中(更不会触发对应的idc)。
而如果表达式中涉及到了变量,那么就会执行另一个流程:创建一个stringBuilder空对象,然后每个+都是一次append操作。最终返回的是一个string对象,因此一定会创建一个新的string对象。但是有一个例外:如果变量是一个被final所修饰的常量,依然可以被编译优化。(只能是string或基本类型,引用类型(除了string)没有常量一说)

1.5及其以前+默认采用stringBuffer,是线程安全的,但效率低。1.6之后采用效率高但是线程不安全的stringBuilder。
线程安全下面会介绍。这里简单理解:如果有一个语句 s = “”+a。(线程安全问题出现的前提是操作共享资源,因此s肯定不能是一个局部变量)如果三个线程同时执行该语句,就会存在s可能最终的值为a。而我们的预期是,每个线程执行一次该操作,s应该为aaa。正是因为每个线程执行该语句的行为不是一个原子的(事务的),因此存在写写冲突——数据被覆盖、更新丢失。

==与equals

==是基于值的比较,比较的就是内存空间上的值。不管是堆内存分配的内存空间,还是栈内存。内存空间说白了就是一个存放值的字节数组。基本类型,字面值被以二进制(8位一个字节)的方式存放在这个名为内存空间的字节数组中,而引用类型同理,但是存放的是地址值。
所以说,等号是最底层的,直接比较的就是这个“字节数组”的内容。而equals是一个方法,这个方法是object类定义的实例方法,也就是说任何一个对象都具有equals方法。各个对象都有各个对象逻辑上的相等,这是基于“对现实如何建模的思考”,毕竟面向对象的核心的就是对世界万物的建模。因此equals是抽象的,是与具体类型有关的。
string无法也就是java官方为我们提供的类型,equals方法同时也被java官方根据“如何将两个string看作相等的思考下”进行了实现。

因此,作为面向对象语言,我们应该更主要使用equals方法,而不是等号。

String不可变的意义

线程安全

线程安全的问题是什么?我来谈谈我的思考

对线程安全的思考

线程安全——线程肯定指的是多线程的环境,而安全应该指的是共享资源的访问安全问题
jvm的运行时数据区,堆内存是共享的,而栈内存是线程独有的。栈不具有有线程安全问题,即局部变量保存的值在每个线程的栈帧局部变量表都保存了,人人都有一份拷贝的值(dup),没有线程安全问题。而堆内存是线程共享的,如果一个对象存放在堆中,就像被街上的黑板。无法保证共享资源不被竞争访问。
同步或互斥的竞争,那么倒也没什么——虽然5个线程都想要修改黑板上的数字,但是他们是互斥的(1画完2画,2完了3接着),或者同步的(总是能保证123…按顺序画)。但是你不能1画到了一半,2把粉笔抢过去了,画完了1接着画,把2画的给盖住了。(这个问题的出现就是线程安全问题出现的关键原因:原子性无法保证,除此之外还需要保证可见性,2开始画的时候要知道哪里已经被画过了)

试想如果保证了原子性,1画的时候2不能抢,2画的时候1不能抢。从开始画到画完的整个过程都是原子的。但是保证线程安全也是会拉低性能的,加入1画完了,拿着粉笔上厕所(流程还没有走完),上了二十分钟回来交接粉笔,这个时候别的线程必须等他。保证线程安全就免不了资源被独占(这里仅考虑同步与互斥保证线程安全)。

线程安全问题也可以类别数据库中事务的概念,我们把一个操作定义为一个事务,例如ArrayList插入一个值,那么这个接口内所有操作执行的所有代码都应被算入事务的一部分(判断扩容、数组拷贝、插入…),如果我们先要保证线程安全,那么这些流程必须是一个事务——原子的、一个线程执行的过程不能被其他线程打扰。在java中的体现就是:一个“事务代码”就是一个同步块(synchronized或者锁域)包裹的代码,同一时间仅有一个线程正在执行此代码。

从上面的描述可以看出:线程安全出现的前提,一定是存在一个共享资源,如实例成员或静态成员。然后多线程环境下并发访问(读写/写写)这个共享资源。

class Resource{
    private int num =10;
    public void update(){   //如果想要线程安全,就加入synchronized
        num++;
    }
}

通常都会定义一个资源类,然后资源类包含一个成员以及操作它的方法。然后我们(在主方法)创建这个资源类的对象,以及一组线程去并发操作。以num++为例,num++不是一个原子操作,它分为【1】int temp = num + 1 【2】num = temp 两个操作
我们如果将这个操作看作一个事务,那么一个线程执行这个操作的期间不能被打断,如果可以被打断,我们称这个操作为线程不安全的。
【1】读写冲突:这里最直观的就是,一个线程正在执行这个“共两部操作的事务”,如果另一个线程能看到该线程执行到一半的num的值,那么就是读写冲突,用数据库的话语就是——读到事务未提交的值
【2】写写冲突:线程A执行到一半被打断,num被线程B修改,而线程A最终会覆盖这个值,而造成线程B修改的值丢失。即更新丢失

总结:
线程安全发生的场景:多线程环境下,线程竞争共享资源。通常是多线程竞争访问资源类对象
解决方法无非是:加锁、无锁(直接CAS操作资源)、隔离(threadLocal),这里不再展开。

String的线程安全

String如何线程安全?当然是它的不可变性。之前提到,一旦线程想要修改string,则当前string就“准备告别使用”了,接着会创建一个新的对象。

       String s ="初恋";
       s="初恋2";//和初恋告别
        s=s.substring(0,2);//和初恋长得像的另外一个人

字面量仅仅当声明时获得(第一任女友,分手之后除非再次直接叫出她的名字(“再次声明字面量”),否则就永远告别了,就算通过api得到一个类似的她,也只是长得像罢了(本质上是一个新建的对象)),如果修改一个对象,必然会新建一个对象,只不过这个创建的过程被string提供的方法封装了,我们看不见而已。

这就保证了,如果两个线程拿到同一个字面量,那么这个字面量肯定最终指向同一个对象。但是一旦修改,那么修改后的一个是一个新建的对象。(两个线程拿着各自不同的对象),从这个角度想,有点像是copyOnWrite(写时复制)。

实现常量池

之前分析很多了,如果用户仅仅访问字符串,那么持有的字面量将一直对应一个存放在字符串常量池中的引用。而既然是常量池,那么内容本身肯定是不能变化的。而这也是引用类型一般不使用常量进行描述的原因(因为它们的内容即成员值可变)

java中的包装类也和string一样,实现了自己常量池(除了double和float,因为范围太大了)

包装类的常量池是基于内部类实现的,如integer的IntegerCache,是懒加载的。这个缓存对象内部是一个(静态成员)数组,当第一次调用integer的相关方法时,会触发IntegerCache的类初始化,向数组中创建对象。这些对象总是被数组每个单元的指针强引用,所以不会被GC。integer缓存默认的范围-128…127,超过这个返回才会创建integer对象,否则直接返回池中引用。上限是可以通过JVM参数调节的。
总结:string常量池基于jvm实现,而包装类常量池基于jdk实现

常量池的内容和代码中出现的直接字面量有关(这里不考虑编译期优化的情景),而动态生成的字符串(new出来的包括+出来的)的引用不会存在池中(除非你主动且抢先intern)。

作为HashMap的key

每个对象都有一个hashCode,这个值被记录在每个对象的对象头中。hashCode()方法默认直接返回这个值。它是根据对象内部地址转换而来的整数值,可以看作对内存地址的抽象。
而java为每个对象提供hashCode的目的,主要是为了这个对象可以存放在hashMap中(源码注释中看的)。

不可变类型可以作为hashCode的key,而且string的描述性更强。
设想一下,你有一个可变对象例如ArrayList,它的hashCode和equals和它的内容直接相关,而它的内容是可变的。现在使用ArrayList对象(严谨的说,是引用)作为key如果一个对象填入时数组内容为1,之后这个数组修改为1,2。那么使用当前的引用是取不出原来的value的,因为hashCode和equals的返回值都变化了。这时就造成了内存泄露,但是最关键的是value它取不出来了!!!

因此使用不可变对象作为可以hashMap的key(string与8个包装类)。其中string的描述性更强。

string的优化

string的底层是char[]类型的对象,而char是基于utf-16编码。utf-16是Unicode标准下的编码规范。
可以看看之前的文章
我们常用的字符编号在0…0xffff,这个范围也称为BMP。而utf-16编码统一将这个范围内的码点编码为两个字节。这个范围之外的变为四个字节(一般我们用不到)。

utf-8也是变长编码,但是利用率更高,ASCII只占用一个字节,汉字占用三个字节。utf-8可以将码点根据范围编码为1、2、3、4个字节。但是由于string需要支持charAt随机访问字符数组,而utf16的char数组更容易实现

JDK9 为了节省string占用的内存空间,已经将底层的数据结构改为字节数组了。而且根据字符串的内容自动选择编码方式(既然底层是字节,那么将字符存储为字节的过程必然涉及到编码)。一般出现汉字之类的,还是会采用utf-16编码,但是如果都是一些数字英文之类的,会采用Latin1编码。

可以理解为jdk9的字符串底层使用字节数组存储数据,同时自适应编码方式去将字符编码为字节。

计算机的最小存储单元是字节,如果向存储一个字符串,那么免不了将字面量编码为字节去存储。而当用户需要访问字面量时,也会做一个解密操作。

优化后的效果:string(尤其是常量池指向的对象)占用的内存空间减少了,可用的内存资源增多了(不那么吃紧了),GC频率也会有所调整,GC频率少一些,用户线程也不会因为GC而stop the world,用户线程工作有效时长也会跟着增加

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值