如果想要去讨论Stirng特性,那么String源码自然是最值得讨论的东西,应该说,对于最常用的工具,去阅读一下其底层实现自然是十分值得的,自然在一篇文章中讨论4000多行代码显得不现实,且多数方法并不值得去深究讲述,再此,单独领出一些值得讨论的部分
/** The value is used for character storage. */
private final char value[];
当讨论其String的特性,我们几乎所有的注重点都用于放在value数组之上,确实,String在应用时本身只是一个字符的容器而已,其存储数据的部分自然最受人关注再此,我将其值得讨论的点逐一分析
不论是value被final修饰,还是围绕String建立常量池的体系,似乎我们都默认一个事实,String中的值应该是不变的,但是确切的说:
理论上String的值是不变的,但是可以被反射修改,但是使用反射修改会造成副作用,看下面的代码
String a = "trys";
System.out.println("第一次输出:");
System.out.println("a = "+ a);
Field a_ = String.class.getDeclaredField("value");
a_.setAccessible(true);
char[] value =(char[]) a_.get(a);
value[3] = '&';
System.out.println("第二次输出:");
System.out.println("a ="+a);
System.out.println("第三次输出:");
System.out.println("trys");
/*输出内容:
第一次输出:
a = trys
第二次输出:
a =try&
第三次输出:
try&
*/
先不管第三次最离谱的输出,我们可以知道第二次输出,可以证实,字符串对象a的成员变量value确实被修改了数值
但是,我们应该要记住,这绝对不是值得推崇的事情,第三次输出的原因实际就输出a和输出"trys"对于解码器做的其实都是同一件事——将常量池中的第n个值的引用取出,并进行输出
这个问题似乎有些多余,毕竟char是基础数据类型中唯一的字符类型
但是实际上,char并不是理想的容器,一个char占用两个字节,但是大多数的字母,数字等都只需要一个字节即可,而他们要存入char依然会占用两个字节,这似乎无可厚非,因为若不统一一个具体的长度值那在进行字符操作时只会面临两种无法接受的结果:要么出现操作错误,要么,为了省空间付出巨大的时间代价
这么说,似乎合情,因为固定长度方便管理,所以每个char占用两个字节,那么,从具体实现上来解释呢?
根据资料查询会查出,
“因为JVM底层使用的是UTF-16编码,且不论char设置为何种编码,最终都会转换为UTF-16,且只占用两个字节,因此只占用两个字节,且只能表示BMP的字符”
面对这一串让人似懂非懂的解释,虽然可以就这样粗略的带过,但对于我来说是不满足的,因而,我决定继续深究下去:
众所周知,无论是我们写的什么语言,计算机都看不懂——实际上,它们只能读懂二进制的0和1,我们所采取的解决方法则是采取字符编码,也就是使用一个统一的翻译标准,该种标准被我们称为:字符串编码
在此要求下,第一个被推出的是美国人民使用的ASCII标准,包括了26个字母和众多常用的符号,但是,只符合一种语言的编码自然无法满足我们的使用需求
于是,在渴望一个统一字符编码的需求的推动下,诞生了沿用至今也是最为泛用的编码标准:unicode,就结果而言,在该标准下,他们使用了32到64bit的容量(也就是2到4字节)的容量进行字符的存储,其容量达到了包括表情字符也有容纳进去的级别
既然标准已定下,我们自然需要一个实现方案,也就是将对应的字符,以约定的格式存储进内场的方案。
目前使用的最为广泛的自然是UTF系列的转换格式,其共有两个版本,提供了三种编码格式:UTF-8,UTF-16,UTF-32
在具体讨论这三种实现前,我们先作为工程师的角度来思考我们有哪些方案吧:
方案一: 每个字符都使用定长的内存,会提出这样的方案需求显而易见,对于希望每个数组以固定长度存储,即空间换速度的场合自然会选择这种方案
方案二: 每个字符采用不同的的内存长度进行存储,显而易见,方案一带来的空间浪费是许多场合下不能接受的,比如对于使用英语的国家,其存储利用率不到百分之50,但是这带来的副作用是,我们如何区分出每个独立的字符?
举个例子:
若:
0010 0010 = '7';
0010 = '1'
那么内存里中出现了:
0010 0010
我如何区分它是两个1还是一个7?
为了让读取时可以进行区分,我们有两种方案:
1.对码表进行特殊规范
如,需要使用一位表示的,所有都要求以一个0开头,用两位的,用两个0开头,以此类推——这显得十分不现实,因为这让码表需要的位数徒增了许多,与节省内存的初步目标违背
2.使用一种特殊规范来转存
可以在存储时使用一种特殊规范进行存放,读取时再转换为字符真正对应的编码用于显示
如我们依然遵守:
0010 0010 = '7';
0010 = '1'
但是存储时变为:
xx0010 xx0010
x00010
此时,我们根据首位的x数量来区分出1和7,在输出时去掉x即可
其缺点也同样十分明显,编码的读取增加了转换一次的过程,降低了速度,是性能上较低的方案
那么,回到我们的UTF,在设立UTF时,我们的unicode标准已经确定,自然不可能使用第二种方案的第一类型,接下来,我们将分别讨论这三种UTF转换标准具体的实现方案。
UTF-8
utf-8几乎是我们见过最常见使用最广泛的类型了,其名字并不是只占8bit的意思,它是一种可变长度的存储方案,即采取了第二种方案的第二种实现类型,根据需求,对于不是特别追求的性能的地方(包括文本显示等)都会采用他,他将会占用1到4字节不等的内存,其具体规则为:
0xxxxxxx:单字节编码形式,这和 ASCII 编码完全一样,因此 UTF-8 是兼容 ASCII 的,而这部分被称为BMP;
110xxxxx 10xxxxxx:双字节编码形式;
1110xxxx 10xxxxxx 10xxxxxx:三字节编码形式;
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx:四字节编码形式。
以下为具体实例
字符 | N | æ | ⻬ |
---|---|---|---|
Unicode 编号 | 01001110 | 11100110 | 00101110 11101100 |
UTF-8 编码 | 01001110 | 11000011 10100110 | 11100010 10111011 10101100 |
UTF-32
在unicode2.0推出时新增的类型,是因为在unicode1.0时使用的二字节并不够存放全部的字符而推出的,采取定长的4个字节存放全部的字符,其内部存储的直接就是unicode编号
UTF-16
实际上是最早推出的方案类型,在unicode1.0时,和现在的UTF-32一样使用的是定长存储,即全部字符都使用两个字节。也因此,JVM采用了UTF-16进行存储以提高性能,而在UTF-32推出后,UTF-16变为了半定长半变长的类型:
在一定范围中(10000~10FFFF 之间)和UTF-32一样直接在两个字节的内存中存放编号。
否则,和UTF-8一样在四个字节中存放经过转换了的字符码
以下为具体实例:
Unicode 编号范围(十六进制) | 具体的 Unicode 编号(二进制) | UTF-16 编码 | 编码后的字节数 |
---|---|---|---|
0000 0000 ~ 0000 FFFF | xxxxxxxx xxxxxxxx | xxxxxxxx xxxxxxxx | 2 |
0001 0000—0010 FFFF | yyyy yyyy yyxx xxxx xxxx | 110110yy yyyyyyyy 110111xx xxxxxxxx | 4 |
此时,我们再看之前的结论:因为JVM底层使用的是UTF-16编码,且不论char设置为何种编码,最终都会转换为UTF-16,且只占用两个字节,因此只占用两个字节,且只能表示BMP的字符
是不是清晰多了?
values值一直使用char数组直至jdk8,而在jdk9中,String的空间使用问题得到了优化
values变量由char[]数组改为了byte[]数组
并增加了变量coder和latin-1协助维护
总的来说:
若字符串中不存在站位超过1字节的字符,那么coder=0,latin=1,将会没个字符占用一个字节,jvm对它将使用latin-1字符集进行存储(latin-1是iso的标准集之一,你可以理解为unicode和asc相同的那部分)
若存在,则coder=1,latin=0,每个字符占用两个字节,jvm底层依旧使用utf-16进行存储
当然,改变存储方式将会带来许多算法的调整,如取出字符串的第二位字符,由于占用字节数并不确定,将会带来紊乱,而String底层很聪明,它让这些与具体取第几位的函数的指针全部右移了coder个字节。
首先,hash是什么?
如果读过数据结构算法,对于hash是什么自然不会陌生,
通常来说,hash指一系列通过某些算法得出hash值的方法
而这些hash值的集合则成为hash表
举个简单的例子,
比如,一张hash表有1,2,3,4,5,6,7,8八个hash值
此时我要存放一个数值如101,使用的hash算法为直接取个位,那么,因为其个位为1,那么我就把它放在数字1,而一个数字或者一个对象通过某种算法得出的数值,就叫做hash值
那么这样的意义何在,答案很简单:增加比较的速度
最直接的例子,我们都知道,要在HashMap中存放东西需要一个保证不重复的key值
如果交给我们设计,我们会如何实现判断这个key是否已使用呢?
看起来似乎很简单,每次存入时,把已经存入的值全部比较一遍就可以了不是么?
但是如果单纯的使用这种算法,当存入第1000w个值时,我们就要和其余999w个值进行一一比较,这个级别的性能损耗是我们不可能接受的,于是此时散列表就能够分担这方面的运算次数了,举个较为简单的例子:
有集合中有值:10,12,14,51,20
hash算法为,除以10的余数
于是产生四种hash值,合有hash表:0,2,4,1
此时,存入值:30
hash值为0
符合hash值=0的数有10,20,此时,30只需要和10和20执行两次equals算法即可!
在这种hash值算法的支持下,即使存到相当巨大的量时插入新的成员,依然只需进行少量的比较方法就可确认当前的值是否相等了
若一个对象的类没有对equals和hashcode进行任何重写,equals和hashcode的实现分别为:
equals:
public boolean equals(Object obj) {
return (this == obj);
}
hashcode:
public native int hashCode();
可见,默认情况下,equals执行的是对两个对象的具体引用进行比较,而hashcode则是一个native方法,其具体实现是通过对象的具体地址计算得出
总的来说,因为equals默认使用的是直接比较具体的引用地址,而hashcode则自然应该是equals的子集,所以,默认使用的是引用地址通过某种计算得出hash值的做法
再来让我们看看String中妖孽的hashCode
/** Cache the hash code for the string */
private int hash; // Default to 0
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;
}
在我曾经觉得hashCode就是对象地址的代称时,我认为String的hash值取法简直不可理喻,因为若是通过这个算法,实际上value[]值相同的两个数组对象就能得到两个相同的hash值
但是现在,了解了hash究竟为何及其作用后,自然可以知道,hashCode实际上是为当前类的equals比较方法服务的
在String中,equals被重写为:
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
可以看出,String的equals被重写为:只要值相同就应该返回true
那么,为其服务的hash自然也要重写为:通过内容值生成内容
同时,由于String的庞大比较量,String会在对象中直接存放一个hash值作为缓存变量,我们可以看到,当第一次调用hashCode方法时,String对象中的hashcode便将产生的值储存,之后再次调用就直接返回存储的值,规避了每次都需要调用方法计算hash带来的负荷
小结:hash值并不是具体的内存地址,这是Object类定义的Hashcode方法带来的刻板印象
对于Object来说,equals是直接比较对象的内存地址,因此hashcode通过对象的内存获取
对于String来说,equals除了内存地址,只要值相同就返回true,因此String中的hashcode通过对象的内容进行生成
在String中,其继承了3个接口,分别是charSquence(字符队列),comparable(可比较)和seriazble(可序列化)
前两种很好理解,但是第三种似乎有些意义不明,究竟何为序列化?
首先,为何需要?
在java应用中有一种控制模式被称为Facade 模式,该类模式旨在,客户机要正常运行必须要从主机端获得一个正确的对象,才能正常执型功能的模式,客户端必须从服务器端取的一个带有正确特性的对象,因此我们需要将对象进行传输,而序列化,就是将对象转换为字节流,以用于传输或者保存
在实现了序列号的类中,通常会被要求定义一串序列号,
/** use serialVersionUID from JDK 1.0.2 for interoperability */
private static final long serialVersionUID = -6849794470754667710L;
该类序列号实际作用其实是作为识别的版本号,通常分为:
在之前提到的facde模式下,为了接受来自客户端的对象,将其反序列化,自然需要客户端有一模一样的类以还原复活传来的对象
除此之外,还将验证客户端中的版本号是否与服务器中的类包含的版本号相同,可以用作限制个别用户不能使用(如vip用户的序列号和普通用户的不同)
也可用与要求用户更新客户端
在序列化对象时,可能会出现某些字段不希望会被客户端获取的情况,此时,一共有三种处理方案
一类:使用transient修饰符
被transient修饰符修饰过的变量将不会序列化,在客户端还原时直接置为初始值
实例
public class test implements Serializable{
private static final long serialVersionUID = 1L;
transient String name;
//不愿透露姓名的A女士
Integer age;
}
二类:使用serialPersistentFields数组
存入serialPersistentFields数组用于指定哪些变量是默认需要序列化的,其权限比transient更高,被加入数组的字段将会序列化,若未加入则不会
public class test implements Serializable{
private static final long serialVersionUID = 1L;
String name;
Integer age;
private static final ObjectStreamField serialPersistentField ={
new ObjectStreamField("name", String.class),
new ObjectStreamField("age", Integer.Type)}
}
在Stirng中出现了该类数组,且设置为空
private static final ObjectStreamField[] serialPersistentFields =
new ObjectStreamField[0];
理论上,该种配置下的类的对象在序列化时将不会吧任何字段序列化,但是,根据实验
String test = new String("1234");
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("resultString.obj"));
out.writeObject(test);
out.flush();
out.close();
t017.staticVar = 10;
ObjectInputStream oin = new ObjectInputStream(new FileInputStream("resultString.obj"));
String test2 = (String) oin.readObject();
oin.close();
可见,hash值确实没有被序列化,因此是初始值,而value却成功通过了序列化
于是,按照String的原配置来实现一个测试类
public class t020 implements Serializable {
private static final long serialVersionUID = 1L;
private static final ObjectStreamField[] serialPersistentFields =
new ObjectStreamField[0];
private final char[] coders = new char[3];
private int hash;
public static void main(String[] args) throws Exception{
t020 test1 =new t020();
test1.coders[0]='1';
test1.coders[1]='2';
test1.coders[2]='3';
test1.hash=777;
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("String.Object"));
out.writeObject(test1);
out.flush();
out.close();
ObjectInputStream in = new ObjectInputStream(new FileInputStream("String.Object"));
t020 test2 = (t020)in.readObject();
in.close();
}
}
其结果与String不同,呈现的是理论的结果,value和hash都未成功反序列化
我翻阅了众多资料,都无法找到为什么String使用这样的配置,能够让value数组成功被序列化并还原
转换一个思路,进入输出流输出的文件,我确实可以在文件中看到value的值被序列化并存储了
既定事实是:
1.String 通过serialPersistentFields 数组,宣明了自己的所有字段都不序列化,并且,该数组在String中是final的,并且之后没有在String中出现过任何一次
2.Stirng对象序列化后的文件中存在其字段之一——value数组的值在其中,但是没有hash的值
那么我认为,应该是执行写入的ObjectOutPutStream对象提供的writeObject方法对String提供了特殊的服务,进入源码后,我看到确实其底层方法在对象为String类型时,将会调用一个特别的方法
该方法的尽头,我们看到他将会特别的为String对象调用getchars方法,并从中取得String的值
总的来说:
String本身确实宣言了:我的所有字段都不进行序列化
但是wrtieObject方法本身判断,若是Stirng类型,则通过getchars方法直接取走他的字段array
而且,我们可以看到,除了Stirng类,还有其他数个类也拥有类似的“特权”,但是我认为这并不影响我们使用这些关键词执行权限管理——毕竟我们需要管理的的都是我们自主创建出的“新”对象,当然不必担心底层方法为我们设立一些“特权”
关于为什么会有这种特权的存在,我们来讲个类比的小故事:
String是核心骨干老员工,平时每天习惯带着手套上班
然后来了个新来的管理员,他下规则,所有人进门必须脱手套,戴手套工作效率低
但是String一直带手套而且带手套效率更高,而且如果强行要他改习惯他很可能产生错误——在现实中你可以理解为他会不满而跳槽
因此应该做的是,让管理员记住,看到小明不要让他脱手套,而不是强行让小明习惯脱掉手套
该种方法比上述两种都要更加简单,总而言之是利用一条特性,若B继承自A,B实现了序列化,而作为父亲的A没有,那么,A中的成员变量是不会被序列化的
在B被反序列化时,将会先用A中的默认构造方法创造一个全部为初始值的父类,然后再反序列化对象B,这样,即使生成多个子类,我们只要统一把他们想要隐藏的字段放在父类中存放,就能通过了
注意,该特性笔者只在jdk8的环境下实验可以通过,在网上查阅资料时出现6和11分别会将父类直接序列化和直接报错的情况,请酌情使用!!
需要注意的是,对象的序列化并不会包括静态变量,当一个字段被设置为静态,其效果和被设置为transient是一致的,不过,其权限依然比serialPersistentFields 低
稍微留意一下就可以发现,以上两种用法分别是通过直接让类本身无法序列化、直接让某字段序列化以达成某种程度上的权限控制,实际应用时,可能会会有更深度的需求,比如,我希望某个字段能够被特定的用户获取,而非特定的用户只能取得被加密的字段
这种效果可以通过重写writeObject与readObject的底层以实现
public class t018 implements Serializable{
private static final long serialVersionUID = 1L;
private String password = "pass";
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
private void writeObject(ObjectOutputStream out) {
try {
PutField putFields = out.putFields();
System.out.println("原密码:" + password);
password = "security";
putFields.put("password", password);
System.out.println("加密后的密码" + password);
out.writeFields();
}
catch (Exception e) {
e.printStackTrace();
}
}
private void readObject(ObjectInputStream in) {
try {
GetField readFields = in.readFields();
Object object = readFields.get("password", "");
System.out.println("要解密的字符串:" + object.toString());
//模拟解密,此处请替换为使用某种本地的秘钥为参数,才能翻译的一个算法
if (password.equals("security")) {
password = "pass";
}
}
catch (IOException e) {
e.printStackTrace();
}
catch (ClassNotFoundException e) {
e.printStackTrace();}
}
public static void main(String[] args) {
try {
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("result.obj"));
out.writeObject(new t018());
out.flush();
out.close();
ObjectInputStream oin = new ObjectInputStream(new FileInputStream("result.obj"));
t018 t = (t018) oin.readObject();
System.out.println("解密后的字符串:" + t.getPassword());
oin.close();
}
catch (FileNotFoundException e) {
e.printStackTrace();
}
catch (IOException e) {
e.printStackTrace();
}
catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
比起此处举例的玩笑般的加密,通常的实际应用会结合加密秘钥,如md5加密可以追加额外字段salt,若解密放的类的解密代码中没有这段特殊的字段salt,则不能进行解密,我们可以让特殊用户的客户端拥有这个字段,而实现只有部分用户可以获得固定字段的效果