Java学习笔记:2022年1月6日

Java学习笔记:2022年1月6日

摘要:不可变字符串为什么不可变?StringBuffer类与StringBuilder类,字符串操作拾遗,记事本原理,进制转化问题。

1.深入探讨不可变字符串

​ 在1月4日的笔记中,我们详细介绍了Java语言中的字符串类型以及它的特性:不可变,那么字符串为什么是不可变的呢?在Java中所有的字符串都是不可变的吗?接下来我们将对于这个问题展开深入的探讨。

​ 字符串为什么是不可变的?这个问题其实和字符串的实现方式有着密不可分的联系,字符串的基本字符存储机制是使用的字符数组,也就是字符串的实现使用了数组,我们都知道,无论是在C语言中还是在Java中,数组都是长度固定的一种东西,它的长度一旦声明,就无法变化,在Java中,字符数组可以通过下标进行原地址上的修改,而在Java中,连原地址上的修改都无法实现,这是为什么呢?这一切还是要从数组这个东西说起。

1.数组的特质

​ 如果你简要了解过一些计算机底层的知识,就会知道数组尽管是一种简单的数据结构,但是它是一种极为重要的数据结构,在操作系统中,存在着很多各种各样的程序运行栈,管控队列,进行表之类的东西,在Java运行时中,栈区的线程栈,就是使用数组实现的,为什么我们不用链表实现而是用数组实现他们呢?原因只有一个:,是的,数组很快,非常快,对于经常查询而很少做修改的数据,或者说对于栈和队列这种没用从中间插入数据,只在表头进行数据修改的结构来说,数组又快又节省空间,是实现这些结构的不二之选。

​ 数组为什么快呢?这是因为在数组中,里边所有的元素都是定长的,这意味着如果你能获得数组的首地址,使用首地址+下标X元素长度这个公式就能很快的得到你想要查询的元素,此所谓随机存取,这比链表的顺序搜索快的多,同时数组中的元素与元素之间紧紧相邻,除了这些类型的元素再无其他类型的了,不像链表一样还要存储其他信息,因此数字具备这种检索快,占用少的特性。而知道数组的优点之后,我们便可以将数组这一元素放到C语言中和Java语言中做类比,进而审视数组在两种语言中的表现。

2.C语言中的数组和Java中的数组

​ 在C语言中,数组是一种自由的类型,我们可以肆意使用它,以至于让数组越界,在C语言中,数组不是一种安全的类型,它存在越界现象。这个越界对数组本身其实是没什么影响的,顶多就是产生歧义,让我们误以为超出数组范围之后这个数组还能用,但是对于其他程序而言,这可能会导致严重的后果,因为在计算机中,内存上的程序们有可能是相邻存储的,也就是说在内存上,某个数组的后边可能存放的是一个重要的且正在运行的程序,因此轻易的进行数组越界,首先就会覆盖掉这个程序的数据,当这个程序需要这些数据时,就会发生缺页现象,如果这个程序的健壮性不够强,可能连缺页都检测不出来,直接崩溃,如果这个程序是一个系统程序,则会造成更严重的影响,如系统崩溃,因此,数组越界是一个严重的不合规范的危险行为。然而,在C语言中,没有提供任何防止数组越界的机制,我们只能人为的设定标志位,人为的去管控这个行为,这就导致了C语言中的数组并不安全。

​ 数组固然不太安全,可是它又却异常好用,特别是在进行查询遍历以及存储的时候,它都比链表要好得多,因此在Java语言中,设计者将数组设计成了一个绝对安全的类型,Java中加入了防越界机制,首先是最基础的越界报错,对于基本类型的数组,我们在声明好数组长度之后,就无法更改其长度,没有办法在数组被填满之后继续插入数据,这是如果继续在表尾插入数据,就会报错。然而字符数组有着更为严格的保护机制,因为字符的空间占位不是定长的,在众多Unicode编码,乃至其他一些编码中,字符的编码不是定长的,数组为它们分配空间时必须要小心谨慎,比如UTF-16的编码长度有可能是16位有可能是32位,而数组又要求绝对致密,这就导致了16位的字符和32位的字符完全是拼在一起的,当然这里我们不用担心计算机不能识别,在编码的解码程序中提供给了计算机识别不同编码的能力,因此单纯的对于编码来说,计算机可以识别16位的和32位的混合长度编码,然而这对于内存来说并不是好事,因为计算机对人展示的,是一个一个的字符,简而言之,就是无论是32位字符还16位字符,对我们来说都是一个字符。因此我们在进行字符操作时,如果想单独修改字符串中的一个字符时,如果我们打算将其从16位字符修改为一个32位字符,那么问题就大了,它多出去的编码长度会直接覆盖下一个位置的字符,导致整个字符串出现错误,因此单个的字符修改,首先就很危险,与此同时,一些在C语言中很常见的字符扩展,也会直接导致字符数组长度增加,导致内存覆盖问题,在C语言中,这些问题都无法通过自身提供的机制解决,任何草率的操作都会导致整个程序全军覆没,但是在Java中,设计者为了完全的杜绝这些情况,设计了不可变字符串的机制,至于如何不可变,我想前边讲解的已经够清楚了,而至于Java中的字符串为何不可变,原因就在于此。

3.关于内存和硬盘中的存储机制以及Java数组在内存中的真实状态

​ 在1月4日的学习中,我在最后探讨了关于计算机中硬盘和内存的存储机制,在这里,我将结合Java数组再探讨一次,进而阐述Java数组在内存中的真实状态。首先我们定一个基调:在Java中不允许任何数组在当前地址下进行长度扩展,想要长度更长的数组都必须要重新申请空间。

​ 当前计算机的内存和硬盘之中使用的都是分页机制,分页机制在操作系统中是重要的一环,也就是说CPU在从硬盘上取信息时,是一页一页的从硬盘拿到内存,然后再拿到高速缓存的,在CPU中都存在高速缓存,如图:

多级缓存

​ 仔细观察的话,我们可以发现两个缓存都是4KB的倍数,尽管这会引起米4达的反感,但这其中也是有原因的:“当前的操作系统中的单页大小,就是4KB。”这是认为规定的大小,具体数值并没有什么道理,只能是说比较符合当下内存空间以及硬盘空间中的需求,在前面我们也已经说过,计算机中的存储单元如果划分的太小的话会导致CPU性能过剩,等待次数过多,频繁进行从低速存储区索取信息,频烦缺页以及寻址信息过多的现象,人们为了合理的解决这个问题,便制定了硬件和软件的统一规范,在进行实验之后确定了4KB为当前来说比较合适的页大小,这使得在硬盘上,即使是一个最小的文件,它也会占用4KB的存储空间,在内存上,一个变量,一个类型,也会占用一个4KB的页。

​ 因此,数组自然而然也是最少占用一个4KB的页,当然对于内存来说,数组是个大好人,因为它的长度通常来说会比基本变量大很多,因此对于单个页的使用率也非常高,需注意的是尽管在内存中每个基本变量类型都要独自占用一页,数组中的基本类型因为具备数组的特性加持,他们可以致密的排布在一个页中,这也是为什么数组存储效率高而操作系统中都喜欢使用数组的原因之一。当然,这个数组特别长的时候,系统会为他分配符合其长度的,最少的页来存储他,比如有一个数组连续占据了7KB的空间,那么它就是横亘在这7KB上的一串连续空间,系统这时会为它分配两个页,即8KB来存储他,需要注意的是,系统分配内存空间时,是按照变量分配的,而不是按照内存单元分配,或者说按照数组中的基本变量分配。系统在为一个数组分配空间时,是将这个数组看成一个7KB大的单独个体,因此这7KB的信息是致密的连续的分布在这8KB上的,也就是这8KB的空间上的前7KB致密的排布了这些信息,这8KB确实得到了高效的利用,而不是像存储单个变量一样,每个基本变量都占4KB,在数组上的单个基本元素,是真的只占用了它自身真实长度的空间,基本上没有空间浪费,因此注意这个知识点,系统分配变量是按照变量分配的。这个知识点在之后也会有提及。因此,在Java中,数组的存储方式就是在内存中以连续的致密的信息串的形式存在,他们通常占用符合他们长度的最少的页。

​ 因此我们可以得出一个结论:烙饼大不过烙它的饼铛,字符串的长度一定也不会长过分配给它的存储空间。所以我们不禁会想,在存储单元长度允许的范围内,小小的在原地址上修改一下它的长度大小,这多是一件美事啊,然而答案是:这多是一件美逝啊。这种行为是绝对危险的行为,诚然存储数组使用的内存空间通常会比他大,但是我们可以肯定的是最大也大不过4KB的大小,因此我们如果进行大规模的数据添加,当这个添加行为加入的数据大于4KB时,也是非常危险的,更别说当这个数组长7.99999KB的时候,我们只要在里边进行稍微的添加,就会导致数组越界。Java希望整个系统绝对安全,因此是绝对不允许这种可能导致数组越界的行为发生的。因此,在Java中设定了不可变字符串的重要机制。

4.String类型的缺点

​ String类型字符串的不可变机制确实不错,非常安全,但这不代表它没有缺陷,我们在字符串的操作中也见识到了,对于字符串的修改以及拼接非常麻烦,实际上,它不仅麻烦,还占用时间。试想一下,我们如果想进行一个字符串拼接,系统首先会根据两个字符串拼接的长度重新申请一块空间,然而再将两个字符串复制在上面,最后删除原字符串,这是一个繁琐复杂的行为,特别是我们的操作只有拼接并且是频繁进行拼接时,整个系统的效率会非常慢。因此系统中便引出了其他类型的字符串。

2.StringBuffer类和StringBuilder类的出现

​ 为了很好的弥补String在字符串拼接上的不足,人们设计了StringBuilder和StringBuffer两个类,这两个类专门用于进行字符串拼接。接下来我们可以做一个实验:

public class Tester {//使用字符串进行拼接十万次,查看所用时间

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		long start = System.currentTimeMillis();
		String a = "";
		for(int i = 0;i<100000;i++) {
			a += i;
		} 
		long end = System.currentTimeMillis();
		System.out.println("总计花费时间: "+(end - start));
	}

}

​ 我计算机上运行得到答案是:

总计花费时间: 6542

​ 接下来我们使用StringBuilder来进行测试:

public class Tester {

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		long start = System.currentTimeMillis();
		//String a = "";
		StringBuilder a2 = new StringBuilder();
		for(int i = 0;i<100000;i++) {
			//a += i;
			a2.append(i);
		} 
		long end = System.currentTimeMillis();
		System.out.println("总计花费时间: "+(end - start));
	}

}

​ 我计算机上运行得到的答案是:

总计花费时间: 8

​ 可能你在你的计算机上两个答案和我的略有差别,但是可以肯定的是,使用String类型进行拼接花费时间要多得多,相差是百倍乃至于千倍。这是为什么呢,这就是因为二者底层的实现机制不太一样。同时,需要注意在Java中还存在StringBuffer类型,它是StringBuilder在多线程下的安全版本,内部实现原理同StringBuilder一样,只不过是为了多线程下的安全加了锁,因此相关操作比StringBuilder慢一些。

3.StringBuilder与StringBuffer实现的基本原理

​ 我最初以为二位的实现方法是链表,但实际上不是的,他们并没有使用其他的数据结构,而是使用了新的编程理念以及一些比较复杂的操作。两个类的基本实现仍然是字符数组,只不过他们中增加了一个数组扩张机制以及初始化机制。

这两个类在最初进行初始化时,便会申请一个非常大的存储空间,远远大于4KB,这就导致数组越界很难发生,当然初始值很大不保证他们绝对不越界,因此设计者为它们增加了一个比较有趣的自动扩张机制,当进行字符串拼接时,只要当前的空间足够,就允许在当前地址上修改并进行扩张,当检测到剩余内存不够了,就会再次申请一个新的内存空间,将原字符串复制过去之后,放弃原地址,在新地址上继续进行字符串拼接,这个申请新地址的机制为:申请大小是之前二倍的空间作为新地址。这就导致这个字符串越来越大时,它申请的新空间呈指数上升,越来越难以出现越界现象。因此在字符串进行拼接时,过程中便很难再出现新的地址不够现象进而申请新空间,同时也不会像String类型那样只要进行一次拼接,就发生一次地址申请行为,它可能是在数次拼接之后再进行一次地址申请,这就让浪费时间的行为大幅度减少,进而提升运行效率,因为申请新地址并回收旧地址是一个很浪费时间的过程,只要有效减少这个行为的发生,就能够大量提高整体运行速度。

​ 当然这两个类并不完美,首先当我们的字符串很少进行拼接且要求占用空间较少时,比如存储姓名信息之类的短字符串,并很少进行修改时,他们的功能会显得非常多余且浪费空间,因此,在Java中,很少能绝对的说一个类型是好是坏,只能是说每个类型有着自己合适的应用场景,在特定的应用场景下,某个类型可能表现得很好,当场景变更,它可能不会再是最优选择。

​ 在很多高级语言中都存在类似这两个类的类型,因此我们有理由认为,这两个类的理念并不是Java独有的,它们是超越语言的编程理念,好的编程理念往往比熟练使用编程语言更为重要。在这个环节的最后我附上我的笔记草稿,从而在以后能够从当时学习的思路上找到新的想法,这里边的笔记不一定对,因此慎重观看:

	字符串是不可变字符串,因此在每次拼接时都要开辟一块新的空间。
	内存的默认存储单元是一个字节
	操作系统在读任何数据之前,首先要把地址的指令发过去,指定在哪读,然后才能获得那个地址的数据。这个是根据计算机的存储器机制决定的,在计算机中,CPU向存储器发送一个01代码,即地址,然后存储区反馈出该地址的信息。
	通常CPU读取一次信息的时间是15~29纳秒,也就是从内存中读取一个单位用的时间。当单个块增大时,单元减少,读写次数减少,获取同样大小的信息的时间自然而然也会减少。CPU单词读写数据的速度很快,读了多少时间差异不太大。单个的存储单元越大,性能越快。
	当单个存储单元定的比较大的时候,里边可能存储多个数据,可能有信息找不到。单个存储越大,空间浪费越严重。
	存储单元越大,性能越快,但是空间浪费越严重,存储单元越小,性能越慢,但是空间利用越充分。
	操作系统对内存进行了重新划分,内存和硬盘的单页大小为4kb,也就是一个存储单元的大小,硬盘的页大小是可以变的。内存的页大小通常不能自己改,但会随着时间的发展而变化。硬盘和内存有自己的不同的存储侧重点,内存注重速度,硬盘注重存储质量。现在的单页已经是4kb了。单页的基本大小是随着时代的变化而变化的。
	偏移地址是对存储单元进行划分,使得可以进行更加精细的寻址。
也就是说,一个文件的真实大小通常比实际空间小得多,任何一个变量至少消耗4kb。任何一个文件一个程序也要消耗一个4kb。一个变量实际的存储空间至少是4kb,这是由于操作系统的特性所导致的。偏移地址我记得是汇编语言里边的东西,这个是寻址用的,以便于在某一块区进行更加精细的寻址。
	数组比较节省空间,偏移地址对页内寻址是有很大帮助的,在使用数组时,就会使用偏移地址,这是大量的数据可以被高密度的存储在一个存储空间中,这时只需要掌握数组地址,就可以推算出其他的数字地址,进而进行高效率的高密度存储。在操作系统中我们通常使用数组来进行大量实现,以及算法中用数组也比较多。
	每次消耗存储单元都是以4kb为单位。因此在字符串拼接的时候,每次的消耗量至少为一个4kb,特别是字符串长了之后,每次消耗的资源会更多。
	用String申请的字符串本身是可以往后直接填数据的,因为它本身申请的大小就是4kb的大小。它本身占用的大小就是4kb。java绝对安全,采用了木桶原则,就是不允许往后放,因为怕超出4kb。这意味着我们要进行拼接的话,必须再申请一个新的空间。

	StringBuffer是先申请一个大空间,然后只要原来的空间足够,就不用重新申请新的地址,这样一来效率非常高。StringBuilder不是每次循环都会申请空间,它每次里边都有富裕的空间。这应该也是个侧重的事情。
在每次放入的时候都会判断是否超出了之前申请的空间,空间不够后才会重新申请,然后申请一个更大的空间,然后复制,但是它申请的频率会比以前低。
	用字符串的话,每次都要申请新的存储页,且累计消耗的存储页非常多,StringBuffer申请的存储页以及消耗的存储页非常小,对内存的调号非常少,采取这种策略消耗量非常小,因为它一次申请大空间,然后很长一段时间内不会消耗空间,其次这种乘二的空间增幅量非常稳,n+1项大于前n项和,所以越往后加,其容量越趋于巨大,而需要再次申请空间的机会就会变小,这是一种空间申请策略,专用于大规模增幅数据的内存申请。
	StringBuffer是基于数组的,因此其里边是个字符数组,然后它的使用是有偏向性的,仅有在大规模变化字符串时才会效率高。所有语言都会支撑StringBuilder和StringBuffer这种好的理念,这种只是一种字符串处理策略。
在StringBuffer以及builder中,其内部是基于字符数组实现的,也就是说,每个新字符不会导致新的4kb申请,他们本身也不会占据4kb,而是占据自身的真实大小,如ASCII编码中他们就真的只占1b,普通的String类型其实他们本身也是占据自己真实大小的,然而,只要进行一次声明,他们就会请求一次4kb的存储空间,然后进行一次复制,当字符们的大小超越4kb之后,就会每次申请的越来越多,这是一个等差数列和,而StringBuffer是一个等比数列和,最终占用的数量一样,但是申请过的空间次数以及大小之和就会小的多,Java的自动垃圾回收机制会回收不用的空间,反复的申请并放弃空间是一个非常沉重的负担。这些因素都会导致运行变慢。
	越是高端岗位越不计较语言的类型,学习语言只是学习编程理念或者是问题解决理念的手段,语言只是问题解决理念的表示手段。正如每个人都会说话,每个人都会写字,有人只会骂人,有人可以解决外交问题,有人可以成为作家。

4.字符串操作拾遗

1.格式化输出

​ 如果你学过C语言,那么一定会对格式化输出非常熟悉,因为C语言中的printf函数只能支持格式化输出,它无法进行Java这样方便的字符串拼接,当然格式化输出不是一无是处,它在批量输出格式相同的数据时有着重大的意义,因此在Java中也加入了格式化输出的方法,即format方法。具体使用方法如下:

str=String.format("Hello~~~,%s", "Gentelman!");

​ 即直接调用String类中的方法format,其内部格式和C语言中的格式化输出相当类似,就是使用替换符的方式进行字符替换,一些转义字符的用法也和C语言中的方法一样,比如转义字符:\的各种搭配,这里不再详细解释。关于格式化输出这里又一遍整理的比较细致的博文,大家可以参考java中String的格式化format()方法,以及关于转义字符比较详尽的一篇博文java语言中的转义字符。日后我也会在更加深入的学习中进行整理。

2.获取当前路径的方法

​ 使用这个方法可以获取当前正在运行的程序所在文件的路径,在写项目时经常会用到:

String dir = System.getProperty("user.dir");//返回这句话所在的文件在计算机中的存储路径。

​ 这个dir获取的就是当前的程序所谓的文件夹的路径,这个属于文件操作,在写项目中经常会用到,同时也有更多的深入展开,因此在以后我也会有更多的笔记记录。

5.关于记事本的实现原理

​ 这个问题值得进行更加深入的探讨,详情见链接

6.关于进制问题

​ 这个问题值得进行更加深入的探讨,详情见链接

posted @ 2022-03-25 10:15  云杉木屋  阅读(30)  评论(0编辑  收藏  举报