0%

一、第一范式

列不可再分

1NF是对属性的原子性,要求属性具有原子性,不可再分解;

表:字段1、 字段2(字段2.1、字段2.2)、字段3 ……

如学生(学号,姓名,性别,出生年月日),如果认为最后一列还可以再分成(出生年,出生月,出生日),它就不是一范式了,否则就是;

二、第二范式

主键(可为多列)可以将每一行完全区分开来,且主键不冗余

2NF是对记录的惟一性,要求记录有惟一标识,即实体的惟一性,即不存在部分依赖

表:学号、课程号、姓名、学分;

这个表明显说明了两个事务:学生信息, 课程信息;由于非主键字段必须依赖主键,这里学分依赖课程号姓名依赖与学号,所以不符合二范式。

可能会存在问题:

  • 数据冗余:,每条记录都含有相同信息;
  • 删除异常:删除所有学生成绩,就把课程信息全删除了;
  • 插入异常:学生未选课,无法记录进数据库;
  • 更新异常:调整课程学分,所有行都调整。

正确做法:
学生:Student(学号, 姓名);
课程:Course(课程号, 学分);
选课关系:StudentCourse(学号, 课程号, 成绩)。

三、第三范式

满足一二范式条件下,不存在对主键的传递依赖

3NF是对字段的冗余性,要求任何字段不能由其他字段派生出来,它要求字段没有冗余,即不存在传递依赖;

表: 学号, 姓名, 年龄, 学院名称, 学院电话

因为存在依赖传递: (学号) → (学生)→(所在学院) → (学院电话) 。

可能会存在问题:

  • 数据冗余:有重复值;
  • 更新异常:有重复的冗余信息,修改时需要同时修改多条记录,否则会出现数据不一致的情况

正确做法:

学生:(学号, 姓名, 年龄, 所在学院);

学院:(学院, 电话)。

四、反范式化

一般说来,数据库只需满足第三范式(3NF)就行了。

没有冗余的数据库设计可以做到。但是,没有冗余的数据库未必是最好的数据库,有时为了提高运行效率,就必须降低范式标准,适当保留冗余数据。具体做法是:在概念数据模型设计时遵守第三范式,降低范式标准的工作放到物理数据模型设计时考虑。降低范式就是增加字段,允许冗余,达到以空间换时间的目的

〖例〗:有一张存放商品的基本表,“金额”这个字段的存在,表明该表的设计不满足第三范式,因为“金额”可以由“单价”乘以“数量”得到,说明“金额”是冗余字段。但是,增加“金额”这个冗余字段,可以提高查询统计的速度,这就是以空间换时间的作法。

Rose 2002中,规定列有两种类型:数据列计算列。“金额”这样的列被称为“计算列”,而“单价”和“数量”这样的列被称为“数据列”。

五、范式化设计和反范式化设计的优缺点

5.1 范式化

优点:

  • 减少数据冗余,数据表体积小更新快
  • 更新操作更快

缺点:

  • 查询操作需要多表关联,降低了性能
  • 更难进行索引优化

5.2 反范式化

优点:

  • 可以减少查询时表的关联
  • 更好地进行索引优化

缺点:

  • 数据冗余,维护不方便
  • 修改数据不方便

本文整理自

数据库逻辑设计之三大范式

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


RESTful 是目前最流行的 API 设计规范,用于 Web 数据接口的设计。

它的大原则容易把握,但是细节不容易做对。本文总结 RESTful 的设计细节,介绍如何设计出易于理解和使用的 API。

img

一、URL 设计

1.1 动词 + 宾语

RESTful 的核心思想就是,客户端发出的数据操作指令都是”动词 + 宾语”的结构。比如,GET /articles这个命令,GET是动词,/articles是宾语。

动词通常就是五种 HTTP 方法,对应 CRUD 操作。

  • GET:读取(Read)
  • POST:新建(Create)
  • PUT:更新(Update)
  • PATCH:更新(Update),通常是部分更新
  • DELETE:删除(Delete)

根据 HTTP 规范,动词一律大写。

1.2 动词的覆盖

有些客户端只能使用GETPOST这两种方法。服务器必须接受POST模拟其他三个方法(PUTPATCHDELETE)。

这时,客户端发出的 HTTP 请求,要加上X-HTTP-Method-Override属性,告诉服务器应该使用哪一个动词,覆盖POST方法。

1
2
POST /api/Person/4 HTTP/1.1  
X-HTTP-Method-Override: PUT

上面代码中,X-HTTP-Method-Override指定本次请求的方法是PUT,而不是POST

1.3 宾语必须是名词

宾语就是 API 的 URL,是 HTTP 动词作用的对象。它应该是名词,不能是动词。比如,/articles这个 URL 就是正确的,而下面的 URL 不是名词,所以都是错误的。

  • /getAllCars
  • /createNewCar
  • /deleteAllRedCars

1.4 复数 URL

既然 URL 是名词,那么应该使用复数,还是单数?

这没有统一的规定,但是常见的操作是读取一个集合,比如GET /articles(读取所有文章),这里明显应该是复数。

为了统一起见,建议都使用复数 URL,比如GET /articles/2要好于GET /article/2

1.5 避免多级 URL

常见的情况是,资源需要多级分类,因此很容易写出多级的 URL,比如获取某个作者的某一类文章。

1
GET /authors/12/categories/2

这种 URL 不利于扩展,语义也不明确,往往要想一会,才能明白含义。

更好的做法是,除了第一级,其他级别都用查询字符串表达。

1
GET /authors/12?categories=2

下面是另一个例子,查询已发布的文章。你可能会设计成下面的 URL。

1
GET /articles/published

查询字符串的写法明显更好。

1
GET /articles?published=true

二、状态码

2.1 状态码必须精确

客户端的每一次请求,服务器都必须给出回应。回应包括 HTTP 状态码和数据两部分。

HTTP 状态码就是一个三位数,分成五个类别。

  • 1xx:相关信息
  • 2xx:操作成功
  • 3xx:重定向
  • 4xx:客户端错误
  • 5xx:服务器错误

这五大类总共包含100多种状态码,覆盖了绝大部分可能遇到的情况。每一种状态码都有标准的(或者约定的)解释,客户端只需查看状态码,就可以判断出发生了什么情况,所以服务器应该返回尽可能精确的状态码。

API 不需要1xx状态码,下面介绍其他四类状态码的精确含义。

2.2 2xx 状态码

200状态码表示操作成功,但是不同的方法可以返回更精确的状态码。

  • GET: 200 OK
  • POST: 201 Created
  • PUT: 200 OK
  • PATCH: 200 OK
  • DELETE: 204 No Content

上面代码中,POST返回201状态码,表示生成了新的资源;DELETE返回204状态码,表示资源已经不存在。

此外,202 Accepted状态码表示服务器已经收到请求,但还未进行处理,会在未来再处理,通常用于异步操作。下面是一个例子。

1
2
3
4
5
6
7
8
HTTP/1.1 202 Accepted

{
"task": {
"href": "/api/company/job-management/jobs/2130040",
"id": "2130040"
}
}

2.3 3xx 状态码

API 用不到301状态码(永久重定向)和302状态码(暂时重定向,307也是这个含义),因为它们可以由应用级别返回,浏览器会直接跳转,API 级别可以不考虑这两种情况。

API 用到的3xx状态码,主要是303 See Other,表示参考另一个 URL。它与302307的含义一样,也是”暂时重定向”,区别在于302307用于GET请求,而303用于POSTPUTDELETE请求。收到303以后,浏览器不会自动跳转,而会让用户自己决定下一步怎么办。下面是一个例子。

1
2
HTTP/1.1 303 See Other
Location: /api/orders/12345

2.4 4xx 状态码

4xx状态码表示客户端错误,主要有下面几种。

400 Bad Request:服务器不理解客户端的请求,未做任何处理。

401 Unauthorized:用户未提供身份验证凭据,或者没有通过身份验证。

403 Forbidden:用户通过了身份验证,但是不具有访问资源所需的权限。

404 Not Found:所请求的资源不存在,或不可用。

405 Method Not Allowed:用户已经通过身份验证,但是所用的 HTTP 方法不在他的权限之内。

410 Gone:所请求的资源已从这个地址转移,不再可用。

415 Unsupported Media Type:客户端要求的返回格式不支持。比如,API 只能返回 JSON 格式,但是客户端要求返回 XML 格式。

422 Unprocessable Entity :客户端上传的附件无法处理,导致请求失败。

429 Too Many Requests:客户端的请求次数超过限额。

2.5 5xx 状态码

5xx状态码表示服务端错误。一般来说,API 不会向用户透露服务器的详细信息,所以只要两个状态码就够了。

500 Internal Server Error:客户端请求有效,服务器处理时发生了意外。

503 Service Unavailable:服务器无法处理请求,一般用于网站维护状态。

三、服务器回应

3.1 不要返回纯本文

API 返回的数据格式,不应该是纯文本,而应该是一个 JSON 对象,因为这样才能返回标准的结构化数据。所以,服务器回应的 HTTP 头的Content-Type属性要设为application/json

客户端请求时,也要明确告诉服务器,可以接受 JSON 格式,即请求的 HTTP 头的ACCEPT属性也要设成application/json。下面是一个例子。

1
2
GET /orders/2 HTTP/1.1 
Accept: application/json

3.2 发生错误时,不要返回 200 状态码

有一种不恰当的做法是,即使发生错误,也返回200状态码,把错误信息放在数据体里面,就像下面这样。

1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Content-Type: application/json

{
"status": "failure",
"data": {
"error": "Expected at least two items in list."
}
}

上面代码中,解析数据体以后,才能得知操作失败。

这张做法实际上取消了状态码,这是完全不可取的。正确的做法是,状态码反映发生的错误,具体的错误信息放在数据体里面返回。下面是一个例子。

1
2
3
4
5
6
7
8
9
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
"error": "Invalid payoad.",
"detail": {
"surname": "This field is required."
}
}

3.3 提供链接

API 的使用者未必知道,URL 是怎么设计的。一个解决方法就是,在回应中,给出相关链接,便于下一步操作。这样的话,用户只要记住一个 URL,就可以发现其他的 URL。这种方法叫做 HATEOAS。

举例来说,GitHub 的 API 都在 api.github.com 这个域名。访问它,就可以得到其他 URL。

1
2
3
4
5
6
7
8
9
{
...
"feeds_url": "https://api.github.com/feeds",
"followers_url": "https://api.github.com/user/followers",
"following_url": "https://api.github.com/user/following{/target}",
"gists_url": "https://api.github.com/gists{/gist_id}",
"hub_url": "https://api.github.com/hub",
...
}

上面的回应中,挑一个 URL 访问,又可以得到别的 URL。对于用户来说,不需要记住 URL 设计,只要从 api.github.com 一步步查找就可以了。

HATEOAS 的格式没有统一规定,上面例子中,GitHub 将它们与其他属性放在一起。更好的做法应该是,将相关链接与其他属性分开。

1
2
3
4
5
6
7
8
9
10
HTTP/1.1 200 OK
Content-Type: application/json

{
"status": "In progress",
"links": {[
{ "rel":"cancel", "method": "delete", "href":"/api/status/12345" } ,
{ "rel":"edit", "method": "put", "href":"/api/status/12345" }
]}
}

四、参考链接


本文整理自

RESTful API 最佳实践

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


信号量(Semaphore)是一种控制多线程(进程)访问共享资源的同步机制,是由荷兰的Dijkstra大佬在1962年前后提出来的。

信号量的原理

信号量机制包含以下几个核心概念:

  1. 信号量S,整型变量,需要初始化值大于0
  2. P原语,荷兰语Prolaag(probeer te verlagen),表示减少信号量,该操作必须是原子的
  3. V原语,荷兰语Verhogen,表示增加信号量,该操作必须是原子的

信号量

从上图不难看出信号量的两个核心操作,P和V:

  1. P操作,原子减少S,然后如果S < 0,则阻塞当前线程
  2. V操作,原子增加S,然后如果S <= 0,则唤醒一个阻塞的线程

信号量一般被用来控制多线程对共享资源的访问,允许最多S个线程同时访问临界区,多于S个的线程会被P操作阻塞,直到有线程执行完临界区代码后,调用V操作唤醒。所以PV操作必须是成对出现的。

那么信号量可以用来干什么呢?

  1. 信号量似乎天生就是为限流而生的,我们可以很容易用信号量实现一个限流器
  2. 信号量可以用来实现互斥锁,初始化信号量S = 1,这样就只能有一个线程能访问临界区。很明显这是一个不可重入的锁。
  3. 信号量甚至能够实现条件变量,比如阻塞队列

动手实现一个信号量

学习这些经典理论的时候,最好的办法还是用自己熟悉的编程语言实现一遍。Java并发包提供了一个信号量的java.util.concurrent.Semaphore,是用AbstractQueuedSynchronizer的共享模式实现的,以后会单独分析关于AQS相关的原理,这里不再展开描述,其核心思想是CAS。
下面是我用Java实现的一个简单的信号量,这里使用synchronized来替代互斥锁

信号量实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class Semaphore {
/**
* 信号量S
*/
private int s;

public Semaphore(int s) {
this.s = s;
}

/**
* P原语,原子操作
* <p>
* S减decr,如果S小于0,阻塞当前线程
*/
public synchronized void p(int decr) {
s -= decr;
if (s < 0) {
try {
wait();
} catch (InterruptedException e) {
// ...
}
}
}

/**
* V原语,原子操作
* <p>
* S加incr,如果S小于等于0,唤醒一个等待中的线程
*/
public synchronized void v(int incr) {
s += incr;
if (s <= 0) {
notify();
}
}
}

用信号量限流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Limiter implements Executor {

private Semaphore semaphore;

public Limiter(int limit) {
semaphore = new Semaphore(limit);
}

public void execute(Runnable runnable) {
if (runnable != null) {
new Thread(() -> {
semaphore.p(1);
runnable.run();
semaphore.v(1);
}).start();
}
}
}

用信号量实现互斥锁

1
2
3
4
5
6
7
8
9
10
11
12
public class SemaphoreLock {

private Semaphore semaphore = new Semaphore(1);

public void lock() {
semaphore.p(1);
}

public void unlock() {
semaphore.v(1);
}
}

用信号量实现阻塞队列

实现阻塞队列需要两个信号量和一个锁(锁也可以用信号量代替)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class SemaphoreBlockingQueue<T> {

private Semaphore notFull;
private Semaphore notEmpty;
private SemaphoreLock lock = new SemaphoreLock();

private Object[] table;
private int size;

public SemaphoreBlockingQueue(int cap) {
if (cap < 1) {
throw new IllegalArgumentException("capacity must be > 0");
}
notEmpty = new Semaphore(0);
notFull = new Semaphore(cap);
table = new Object[cap];
}

public void add(T t) {
// 如果队列是满的就会阻塞
notFull.p(1);
// lock保证队列的原子添加
lock.lock();
table[size++] = t;
lock.unlock();
// 唤醒一个阻塞在notEmpty的线程
notEmpty.v(1);
}

@SuppressWarnings("unchecked")
public T poll() {
T element;
// 如果队列是空就会阻塞
notEmpty.p(1);
// lock保证队列的原子删除
lock.lock();
element = (T) table[--size];
lock.unlock();
// 唤醒一个阻塞在notFull的线程
notFull.v(1);
return element;
}
}

本文整理自

信号量机制

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


假设你现在基于远程分支”origin”,创建一个叫”mywork”的分支。

1
$ git checkout -b mywork origin

img

现在我们在这个分支做一些修改,然后生成两个提交(commit).

1
2
3
4
5
$ vi file.txt
$ git commit
$ vi otherfile.txt
$ git commit
...

但是与此同时,有些人也在”origin”分支上做了一些修改并且做了提交了. 这就意味着”origin”和”mywork”这两个分支各自”前进”了,它们之间”分叉”了。

img

在这里,你可以用”pull”命令把”origin”分支上的修改拉下来并且和你的修改合并; 结果看起来就像一个新的”合并的提交”(merge commit):

img

但是,如果你想让”mywork”分支历史看起来像没有经过任何合并一样,你也许可以用 git rebase:

1
2
$ git checkout mywork
$ git rebase origin

这些命令会把你的”mywork”分支里的每个提交(commit)取消掉,并且把它们临时 保存为补丁(patch)(这些补丁放到”.git/rebase”目录中),然后把”mywork”分支更新 到最新的”origin”分支,最后把保存的这些补丁应用到”mywork”分支上。

img

当’mywork’分支更新之后,它会指向这些新创建的提交(commit),而那些老的提交会被丢弃。 如果运行垃圾收集命令(pruning garbage collection), 这些被丢弃的提交就会删除. (请查看 git gc)

img

现在我们可以看一下用合并(merge)和用rebase所产生的历史的区别:

img

在rebase的过程中,也许会出现冲突(conflict). 在这种情况,Git会停止rebase并会让你去解决 冲突;在解决完冲突后,用”git-add”命令去更新这些内容的索引(index), 然后,你无需执行 git-commit,只要执行:

1
$ git rebase --continue

这样git会继续应用(apply)余下的补丁。

在任何时候,你可以用--abort参数来终止rebase的行动,并且”mywork” 分支会回到rebase开始前的状态。

1
$ git rebase --abort

本文整理自

rebase

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


一:流量控制

什么是流量控制?流量控制的目的?

如果发送者发送数据过快,接收者来不及接收,那么就会有分组丢失。为了避免分组丢失,控制发送者的发送速度,使得接收者来得及接收,这就是流量控制。流量控制根本目的是防止分组丢失,它是构成TCP可靠性的一方面。

如何实现流量控制?

滑动窗口协议(连续ARQ协议)实现。滑动窗口协议既保证了分组无差错、有序接收,也实现了流量控制。主要的方式就是接收方返回的 ACK 中会包含自己的接收窗口的大小,并且利用大小来控制发送方的数据发送。

流量控制引发的死锁?怎么避免死锁的发生?

当发送者收到了一个窗口为0的应答,发送者便停止发送,等待接收者的下一个应答。但是如果这个窗口不为0的应答在传输过程丢失,发送者一直等待下去,而接收者以为发送者已经收到该应答,等待接收新数据,这样双方就相互等待,从而产生死锁。
为了避免流量控制引发的死锁,TCP使用了持续计时器。每当发送者收到一个零窗口的应答后就启动该计时器。时间一到便主动发送报文询问接收者的窗口大小。若接收者仍然返回零窗口,则重置该计时器继续等待;若窗口不为0,则表示应答报文丢失了,此时重置发送窗口后开始发送,这样就避免了死锁的产生。

二:拥塞控制和流量控制的区别

  • 拥塞控制
    拥塞控制是作用于网络的,它是防止过多的数据注入到网络中,避免出现网络负载过大的情况;常用的方法是:

    ( 1 )慢开始、拥塞避免

    ( 2 )快重传、快恢复。

  • 流量控制
    流量控制是作用于接收者的,它是控制发送者的发送速度从而使接收者来得及接收,防止分组丢失的。

三:拥塞控制的算法

我们在开始假定:1、数据是单方向传递,另一个窗口只发送确认;2、接收方的缓存足够大,因此发送方的大小的大小由网络的拥塞程度来决定。

慢开始算法

发送方维持一个叫做拥塞窗口cwnd(congestion window)的状态变量。拥塞窗口的大小取决于网络的拥塞程度,并且动态地在变化。发送方让自己的发送窗口等于拥塞窗口,另外考虑到接受方的接收能力,发送窗口可能小于拥塞窗口。

慢开始算法的思路就是,不要一开始就发送大量的数据,先探测一下网络的拥塞程度,也就是说由小到大逐渐增加拥塞窗口的大小。

这里用报文段的个数作为拥塞窗口的大小举例说明慢开始算法,实际的拥塞窗口大小是以字节为单位的。如下图:

img

从上图可以看到,一个传输轮次所经历的时间其实就是往返时间RTT,而且每经过一个传输轮次(transmission round),拥塞窗口cwnd就加倍

为了防止cwnd增长过大引起网络拥塞,还需设置一个慢开始门限ssthresh状态变量。ssthresh的用法如下:当cwnd<ssthresh时,使用慢开始算法。
当cwnd>ssthresh时,改用拥塞避免算法。
当cwnd=ssthresh时,慢开始与拥塞避免算法任意

注意,这里的“慢”并不是指cwnd的增长速率慢,而是指在TCP开始发送报文段时先设置cwnd=1,然后逐渐增大,这当然比按照大的cwnd一下子把许多报文段突然注入到网络中要“慢得多”。

拥塞避免算法

拥塞避免算法让拥塞窗口缓慢增长,即每经过一个往返时间RTT就把发送方的拥塞窗口cwnd加1,而不是加倍。这样拥塞窗口按线性规律缓慢增长。

无论是在慢开始阶段还是在拥塞避免阶段,只要发送方判断网络出现拥塞(其根据就是没有按时收到确认,虽然没有收到确认可能是其他原因的分组丢失,但是因为无法判定,所以都当做拥塞来处理),就把慢开始门限ssthresh设置为出现拥塞时的发送窗口大小的一半(但不能小于2)。然后把拥塞窗口cwnd重新设置为1,执行慢开始算法。这样做的目的就是要迅速减少主机发送到网络中的分组数,使得发生拥塞的路由器有足够时间把队列中积压的分组处理完毕。

整个拥塞控制的流程如下图:

img

(1)拥塞窗口cwnd初始化为1个报文段,慢开始门限初始值为16
(2)执行慢开始算法,指数规律增长到第4轮,即cwnd=16=ssthresh,改为执行拥塞避免算法,拥塞窗口按线性规律增长
(3)假定cwnd=24时,网络出现超时(拥塞),则更新后的ssthresh=12,cwnd重新设置为1,并执行慢开始算法。当cwnd=12=ssthresh时,改为执行拥塞避免算法

关于 乘法减小(Multiplicative Decrease)和加法增大(Additive Increase):

“乘法减小”指的是无论是在慢开始阶段还是在拥塞避免阶段,只要发送方判断网络出现拥塞,就把慢开始门限ssthresh设置为出现拥塞时的发送窗口大小的一半,并执行慢开始算法,所以当网络频繁出现拥塞时,ssthresh下降的很快,以大大减少注入到网络中的分组数。

“加法增大”是指执行拥塞避免算法后,使拥塞窗口缓慢增大,以防止过早出现拥塞。常合起来成为AIMD算法。

注意:“拥塞避免”并非完全能够避免了阻塞,而是使网络比较不容易出现拥塞。

快重传算法

快重传要求接收方在收到一个失序的报文段后就立即发出重复确认(为的是使发送方及早知道有报文段没有到达对方,可提高网络吞吐量约20%)而不要等到自己发送数据时捎带确认。快重传算法规定,发送方只要一连收到三个重复确认就应当立即重传对方尚未收到的报文段,而不必继续等待设置的重传计时器时间到期。如下图:

img

快恢复算法

快重传配合使用的还有快恢复算法,有以下两个要点:

当发送方连续收到三个重复确认时,就执行“乘法减小”算法,把ssthresh门限减半(为了预防网络发生拥塞)。但是接下去并不执行慢开始算法.
考虑到如果网络出现拥塞的话就不会收到好几个重复的确认,所以发送方现在认为网络可能没有出现拥塞。所以此时不执行慢开始算法,而是将cwnd设置为ssthresh减半后的值,然后执行拥塞避免算法,使cwnd缓慢增大。如下图:TCP Reno版本是目前使用最广泛的版本。

img

注意:在采用快恢复算法时,慢开始算法只是在TCP连接建立时和网络出现超时时才使用


本文整理自

TCP流量控制、拥塞控制

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
   /**
* 单例模式-双重校验锁
*/
public class SingleTon3 {

private SingleTon3(){}; //私有化构造方法

private static volatile SingleTon3 singleTon=null;

public static SingleTon3 getInstance(){
//第一次校验
if(singleTon==null){
synchronized(SingleTon3.class){
//第二次校验
if(singleTon==null){
singleTon=new SingleTon3();
}
}
}
return singleTon;
}
}

问题:

为什么需要两次判断if(singleTon==null)?

分析:

第一次校验:用来判断是否需要同步. 由于单例模式只需要创建一次实例,如果后面再次调用getInstance方法时,则直接返回之前创建的实例,因此大部分时间不需要执行同步方法里面的代码,大大提高了性能。如果不加第一次校验的话,那跟上面的懒汉模式没什么区别,每次都要去竞争锁。

第二次校验:如果没有第二次校验,假设线程t1执行了第一次校验后,判断为null,这时t2也获取了CPU执行权,也执行了第一次校验,判断也为null。接下来t2获得锁,创建实例。这时t1又获得CPU执行权,由于之前已经进行了第一次校验,结果为null(不会再次判断),获得锁后,直接创建实例。结果就会导致创建多个实例。所以需要在同步代码里面进行第二次校验,如果实例为空,则进行创建。

需要注意的是,private static volatile SingleTon3 singleTon=null;需要加volatile关键字,否则会出现错误。问题的原因在于JVM指令重排优化的存在。在某个线程创建单例对象时,在构造方法被调用之前,就为该对象分配了内存空间并将对象的字段设置为默认值。此时就可以将分配的内存地址赋值给instance字段了,然而该对象可能还没有初始化。若紧接着另外一个线程来调用getInstance(),取到的就是状态不正确的对象,程序就会出错。


本文整理自

单例模式-双重校验锁缘由

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


Trie树

Trie这个名字取自“retrieval”,检索,因为Trie可以只用一个前缀便可以在一部字典中找到想要的单词。
虽然发音与「Tree」一致,但为了将这种 字典树 与 普通二叉树 以示区别,程序员小吴一般读「Trie」尾部会重读一声,可以理解为读「TreeE」。

Trie 树,也叫“字典树”。顾名思义,它是一个树形结构。它是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。

此外 Trie 树也称前缀树(因为某节点的后代存在共同的前缀,比如pan是panda的前缀)。

它的key都为字符串,能做到高效查询和插入,时间复杂度为O(k),k为字符串长度,缺点是如果大量字符串没有共同前缀时很耗内存。

它的核心思想就是通过最大限度地减少无谓的字符串比较,使得查询高效率,即「用空间换时间」,再利用共同前缀来提高查询效率。

Trie树的特点

假设有 5 个字符串,它们分别是:code,cook,five,file,fat。现在需要在里面多次查找某个字符串是否存在。如果每次查找,都是拿要查找的字符串跟这 5 个字符串依次进行字符串匹配,那效率就比较低,有没有更高效的方法呢?

如果将这 5 个字符串组织成下图的结构,从肉眼上扫描过去感官上是不是比查找起来会更加迅速。

Trie树样子

通过上图,可以发现 Trie树 的三个特点:

  • 根节点不包含字符,除根节点外每一个节点都只包含一个字符
  • 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串
  • 每个节点的所有子节点包含的字符都不相同

通过动画理解 Trie 树构造的过程。在构造过程中的每一步,都相当于往 Trie 树中插入一个字符串。当所有字符串都插入完成之后,Trie 树就构造好了。

Trie 树构造

Trie树的插入操作

Trie树的插入操作

Trie树的插入操作很简单,其实就是将单词的每个字母逐一插入 Trie树。插入前先看字母对应的节点是否存在,存在则共享该节点,不存在则创建对应的节点。比如要插入新单词cook,就有下面几步:

  • 插入第一个字母 c,发现 root 节点下方存在子节点 c,则共享节点 c
  • 插入第二个字母 o,发现 c 节点下方存在子节点 o,则共享节点 o
  • 插入第三个字母 o,发现 o 节点下方不存在子节点 o,则创建子节点 o
  • 插入第三个字母 k,发现 o 节点下方不存在子节点 k,则创建子节点 k
  • 至此,单词 cook 中所有字母已被插入 Trie树 中,然后设置节点 k 中的标志位,标记路径 root->c->o->o->k这条路径上所有节点的字符可以组成一个单词cook

Trie树的查询操作

在 Trie 树中查找一个字符串的时候,比如查找字符串 code,可以将要查找的字符串分割成单个的字符 c,o,d,e,然后从 Trie 树的根节点开始匹配。如图所示,绿色的路径就是在 Trie 树中匹配的路径。

code的匹配路径

如果要查找的是字符串cod(鳕鱼)呢?还是可以用上面同样的方法,从根节点开始,沿着某条路径来匹配,如图所示,绿色的路径,是字符串cod匹配的路径。但是,路径的最后一个节点「d」并不是橙色的,并不是单词标志位,所以cod字符串不存在。也就是说,cod是某个字符串的前缀子串,但并不能完全匹配任何字符串。

cod的匹配路径

程序员不要当一条咸鱼,要向 cook 靠拢:)

Trie树的删除操作

Trie树的删除操作与二叉树的删除操作有类似的地方,需要考虑删除的节点所处的位置,这里分三种情况进行分析:

删除整个单词(比如hi

删除整个单词

  • 从根节点开始查找第一个字符h
  • 找到h子节点后,继续查找h的下一个子节点i
  • i是单词hi的标志位,将该标志位去掉
  • i节点是hi的叶子节点,将其删除
  • 删除后发现h节点为叶子节点,并且不是单词标志位,也将其删除
  • 这样就完成了hi单词的删除操作

删除前缀单词(比如cod

删除前缀单词

这种方式删除比较简单。 只需要将cod单词整个字符串查找完后,d节点因为不是叶子节点,只需将其单词标志去掉即可。

删除分支单词(比如cook

删除分支单词

删除整个单词 情况类似,区别点在于删除到 cook 的第一个 o 时,该节点为非叶子节点,停止删除,这样就完成cook字符串的删除操作。

Trie树的应用

事实上 Trie树 在日常生活中的使用随处可见,比如这个:

具体来说就是经常用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:最大限度地减少无谓的字符串比较,查询效率比哈希表高。

1. 前缀匹配

例如:找出一个字符串集合中所有以 五分钟 开头的字符串。我们只需要用所有字符串构造一个 trie树,然后输出以 五−>分−>钟 开头的路径上的关键字即可。

trie树前缀匹配常用于搜索提示。如当输入一个网址,可以自动搜索出可能的选择。当没有完全匹配的搜索结果,可以返回前缀最相似的可能

google搜索

2. 字符串检索

给出 N 个单词组成的熟词表,以及一篇全用小写英文书写的文章,按最早出现的顺序写出所有不在熟词表中的生词。

检索/查询功能是Trie树最原始的功能。给定一组字符串,查找某个字符串是否出现过,思路就是从根节点开始一个一个字符进行比较:

  • 如果沿路比较,发现不同的字符,则表示该字符串在集合中不存在。
  • 如果所有的字符全部比较完并且全部相同,还需判断最后一个节点的标志位(标记该节点是否代表一个关键字)。

Trie树的局限性

如前文所讲,Trie的核心思想是空间换时间,利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。

假设字符的种数有m个,有若干个长度为n的字符串构成了一个 Trie树 ,则每个节点的出度为 m(即每个节点的可能子节点数量为m),Trie树 的高度为n。很明显我们浪费了大量的空间来存储字符,此时Trie树的最坏空间复杂度为O(m^n)。也正由于每个节点的出度为m,所以我们能够沿着树的一个个分支高效的向下逐个字符的查询,而不是遍历所有的字符串来查询,此时Trie树的最坏时间复杂度为O(n)

这正是空间换时间的体现,也是利用公共前缀降低查询时间开销的体现。


本文整理自

看动画轻松理解「Trie树」

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


SQL UNION 操作符

UNION 操作符用于合并两个或多个 SELECT 语句的结果集

请注意,UNION 内部的每个 SELECT 语句必须拥有相同数量的列。列也必须拥有相似的数据类型。同时,每个 SELECT 语句中的列的顺序必须相同。

SQL UNION 语法

1
2
3
SELECT *column_name(s)* FROM *table1*
UNION
SELECT *column_name(s)* FROM *table2*;

注释:默认地,UNION 操作符选取不同的值。如果允许重复的值,请使用 UNION ALL。

SQL UNION ALL 语法

1
2
3
SELECT *column_name(s)* FROM *table1*
UNION ALL
SELECT *column_name(s)* FROM *table2*;

注释:UNION 结果集中的列名总是等于 UNION 中第一个 SELECT 语句中的列名。

区别

使用union关键字时,可以给出多条select 语句,并将它们的结果合成单个结果集。合并时两个表对应的列数和数据类型必须相同,每个select 语句之间使用union或union all 关键字分隔,

  • union 执行的时候删除重复的记录,所有返回的行都是唯一的
  • 使用union all 关键字的作用是不删除重复行也不对结果进行自动排序

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
mysql> select s_id,f_name from fruits where s_id=101 union select s_id,f_name from fruits where f_price<10;
+------+------------+
| s_id | f_name |
+------+------------+
| 101 | apple |
| 101 | blackberry |
| 101 | cherry |
| 103 | apricot |
| 106 | ahfjwj |
| 103 | cococut |
| 102 | grape |
+------+------------+
7 rows in set (0.00 sec)

mysql> select s_id,f_name from fruits where s_id=101 union all select s_id,f_name from fruits where f_price<10;
+------+------------+
| s_id | f_name |
+------+------------+
| 101 | apple |
| 101 | blackberry |
| 101 | cherry |
| 101 | apple |
| 103 | apricot |
| 106 | ahfjwj |
| 101 | cherry |
| 103 | cococut |
| 102 | grape |
+------+------------+
9 rows in set (0.00 sec)

每个select集是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
mysql> select s_id,f_name from fruits where s_id=101;
+------+------------+
| s_id | f_name |
+------+------------+
| 101 | apple |
| 101 | blackberry |
| 101 | cherry |
+------+------------+
3 rows in set (0.00 sec)

mysql> select s_id,f_name from fruits where f_price<10
-> ;
+------+---------+
| s_id | f_name |
+------+---------+
| 101 | apple |
| 103 | apricot |
| 106 | ahfjwj |
| 101 | cherry |
| 103 | cococut |
| 102 | grape |
+------+---------+
6 rows in set (0.00 sec)

很明显 union all 没有去重,查询的结果还有重复的行.


本文整理自

union和union all 的区别

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


同步(synchronous) IO和异步(asynchronous) IO,阻塞(blocking) IO和非阻塞(non-blocking)IO分别是什么,到底有什么区别?

这个问题其实不同的人给出的答案都可能不同,比如wiki,就认为asynchronous IO和non-blocking IO是一个东西。这其实是因为不同的人的知识背景不同,并且在讨论这个问题的时候上下文(context)也不相同。所以,为了更好的回答这个问题,我先限定一下本文的上下文。

本文讨论的背景是Linux环境下的network IO。

本文最重要的参考文献是Richard Stevens的“UNIX® Network Programming Volume 1, Third Edition: The Sockets Networking ”,6.2节“I/O Models ”,Stevens在这节中详细说明了各种IO的特点和区别,如果英文够好的话,推荐直接阅读。

Stevens的文风是有名的深入浅出,所以不用担心看不懂。本文中的流程图也是截取自参考文献。

Stevens在文章中一共比较了五种IO Model:

  • blocking IO
  • nonblocking IO
  • IO multiplexing
  • signal driven IO
  • asynchronous IO

由于signal driven IO在实际中并不常用,所以我这只提及剩下的四种IO Model。

再说一下IO发生时涉及的对象和步骤。

对于一个network IO (这里我们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:

  • 等待数据准备 (Waiting for the data to be ready)
  • 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

记住这两点很重要,因为这些IO Model的区别就是在两个阶段上各有不同的情况。

blocking IO

在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:

img

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。

对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。

当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

所以,blocking IO的特点就是在IO执行的两个阶段都被block了。

non-blocking IO

linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:

img

从图中可以看出,当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。

用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,用户进程其实是需要不断的主动询问kernel数据好了没有。

IO multiplexing

IO multiplexing这个词可能有点陌生,但是如果我说select,epoll,大概就都能明白了。有些地方也称这种IO方式为event driven IO。我们都知道,select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。

它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:

img

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。

多说一句。所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。

Asynchronous I/O

linux下的asynchronous IO其实用得很少。先看一下它的流程:

img

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。

然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

到目前为止,已经将四个IO Model都介绍完了。现在回过头来回答最初的那几个问题:blocking和non-blocking的区别在哪,synchronous IO和asynchronous IO的区别在哪。

先回答最简单的这个:blocking vs non-blocking。前面的介绍中其实已经很明确的说明了这两者的区别。调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。

在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。Stevens给出的定义(其实是POSIX的定义)是这样子的:

A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
An asynchronous I/O operation does not cause the requesting process to be blocked;

两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。

有人可能会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。

但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。

各个IO Model的比较如图所示:

img

经过上面的介绍,会发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。

而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。

最后,再举几个不是很恰当的例子来说明这四个IO Model:

有A,B,C,D四个人在钓鱼:

  • A用的是最老式的鱼竿,所以呢,得一直守着,等到鱼上钩了再拉杆;
  • B的鱼竿有个功能,能够显示是否有鱼上钩,所以呢,B就和旁边的MM聊天,隔会再看看有没有鱼上钩,有的话就迅速拉杆;
  • C用的鱼竿和B差不多,但他想了一个好办法,就是同时放好几根鱼竿,然后守在旁边,一旦有显示说鱼上钩了,它就将对应的鱼竿拉起来;
  • D是个有钱人,干脆雇了一个人帮他钓鱼,一旦那个人把鱼钓上来了,就给D发个短信。

本文整理自

关于IO同步、异步、阻塞、非阻塞的区别

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


解答

有三点:

1)String 在底层是用一个 private final 修饰的字符数组 value 来存储字符串的。final 修饰符保证了 value 这个引用变量是不可变的,private 修饰符则保证了 value 是类私有的,不能通过对象实例去访问和更改 value 数组里存放的字符。

注:有很多地方说 String 不可变是 final 起的作用,其实不严谨。因为即使我不用 final 修改 value ,但初始化完成后我能保证以后都不更改 value 这个引用变量和 value[] 数组里存放的值,它也是从没变化过的。final 只是保证了 value 这个引用变量是不能更改的,但不能保证 value[] 数组里存放的字符是不能更改的。如果把 private 改为 public 修饰,String类的对象是可以通过访问 value 去更改 value[] 数组里存放的字符的,这时 String 就不再是不可变的了。所以不如说 private 起的作用更大一些。后面我们会通过 代码1处 去验证。

2)String 类并没有对外暴露可以修改 value[] 数组内容的方法,并且 String 类内部对字符串的操作和改变都是通过新建一个 String 对象去完成的,操作完返回的是新的 String 对象,并没有改变原来对象的 value[] 数组。

注:String 类如果对外暴露可以更改 value[] 数组的方法,如 setter 方法,也是不能保证 String 是不可变的。后面我们会通过 代码2处 去验证。

3)String 类是用 final 修饰的,保证了 String 类是不能通过子类继承去破坏或更改它的不可变性的。

注:如果 String 类不是用 final 修饰的,也就是 String 类是可以被子类继承的,那子类就可以改变父类原有的方法或属性。后面我们会通过 代码3处 去验证。

以上三个条件同时满足,才让 String 类成了不可变类,才让 String 类具有了一旦实例化就不能改变它的内容的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public final class String implements Serializable, Comparable<String>, CharSequence {
private final char[] value; // 用 private final 修饰的字符数组存储字符串
private int hash;
private static final long serialVersionUID = -6849794470754667710L;

public String() {
this.value = "".value;
}

public String(String var1) {
this.value = var1.value;
this.hash = var1.hash;
}

public String(char[] var1) {
this.value = Arrays.copyOf(var1, var1.length);
}
......
}

面试问题:String 类是用什么数据结构来存储字符串的?

由上面 String 的源码可见,String 类是用数组的数据结构来存储字符串的

代码1:把 private 修饰符换成 public

我们来看看如果把 private 修饰符换成 public,看看会发生什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 先来模拟一个String类,初始化的时候将 String 转成 value 数组存储
public final class WhyStringImutable {
public final char[] value; // 修饰符改成了 public

public WhyStringImutable() {
this.value = "".toCharArray();
}

public WhyStringImutable(String str){
this.value = str.toCharArray(); // 初始化时转为字符数组
}

public char[] getValue(){
return this.value;
}
}

public class WhyStringImutableTest {
public static void main(String[] args) {
WhyStringImutable str = new WhyStringImutable("abcd");
System.out.println("原str中value数组的内容为:");
System.out.println(str.getValue()); // 打印str对象中存放的字符数组
System.out.println("----------");
str.value[1] = 'e'; // 通过对象实例访问value数组并修改其内容
System.out.println("修改后str中value数组的内容为:");
System.out.println(str.getValue()); // 打印str对象中存放的字符数组
}
}

输出结果:

1
2
3
4
5
原str中value数组的内容为:
abcd
----------
修改后str中value数组的内容为:
aecd

由此可见,private 修改为 public 后,String 是可以通过对象实例访问并修改所保存的value 数组的,并不能保证 String 的不可变性。

代码2:对外暴露可以更改 value[] 数组的方法

我们如果对外暴露可以更改 value[] 数组的方法,如 setter 方法,看看又会发生什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public final class WhyStringImutable {
private final char[] value;

public WhyStringImutable() {
this.value = "".toCharArray();
}

public WhyStringImutable(String str){
this.value = str.toCharArray();
}

// 对外暴露可以修改 value 数组的方法
public void setValue(int i, char ch){
this.value[i] = ch;
}

public char[] getValue(){
return this.value;
}

}

public class WhyStringImutableTest {
public static void main(String[] args) {
WhyStringImutable str = new WhyStringImutable("abcd");
System.out.println("原str中value数组的内容为:");
System.out.println(str.getValue()); // 打印str对象中存放的字符数组
System.out.println("----------");
str.setValue(1,'e'); // 通过set方法改变指定位置的value数组元素
System.out.println("修改后str中value数组的内容为:");
System.out.println(str.getValue()); // 打印str对象中存放的字符数组
}
}

输出结果:

1
2
3
4
5
原str中value数组的内容为:
abcd
----------
修改后str中value数组的内容为:
aecd

由此可见,如果对外暴露了可以更改 value[] 数组内容的方法,也是不能保证 String 的不可变性的。

代码3:去掉 final 修饰

如果 WhyStringImutable 类去掉 final 修饰,其他的保持不变,又会怎样呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class WhyStringImutable {
private final char[] value;

public WhyStringImutable() {
this.value = "".toCharArray();
}

public WhyStringImutable(String str){
this.value = str.toCharArray(); // 初始化时转为字符数组
}

public char[] getValue(){
return this.value;
}
}

写一个子类继承自WhyStringImutable 并修改原来父类的属性,实现子类自己的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class WhyStringImutableChild extends WhyStringImutable {

public char[] value; // 修改字符数组为 public 修饰,不要 final

public WhyStringImutableChild(String str){
this.value = str.toCharArray();
}

public WhyStringImutableChild() {
this.value = "".toCharArray();
}

@Override
public char[] getValue() {
return this.value;
}
}

public class WhyStringImutableTest {
public static void main(String[] args) {
WhyStringImutableChild str = new WhyStringImutableChild("abcd");
System.out.println("原str中value数组的内容为:");
System.out.println(str.getValue());
System.out.println("----------");
str.value[1] = 's';
System.out.println("修改后str中value数组的内容为:");
System.out.println(str.getValue());
}
}

运行结果:

1
2
3
4
5
原str中value数组的内容为:
abcd
----------
修改后str中value数组的内容为:
ascd

由此可见,如果 String 类不是用 final 修饰的,是可以通过子类继承来修改它原来的属性的,所以也是不能保证它的不可变性的。

总结

综上所分析,String 不可变的原因是 JDK 设计者巧妙的设计了如上三点,保证了String 类是个不可变类,让 String 具有了不可变的属性。考验的是工程师构造数据类型,封装数据的功力,而不是简单的用 final 来修饰,背后的设计思想值得我们理解和学习。

拓展

从上面的分析,我们知道,String 确实是个不可变的类,但我们就真的没办法改变 String 对象的值了吗?不是的,通过反射可以改变 String 对象的值

但是请谨慎那么做,因为一旦通过反射改变对应的 String 对象的值,后面再创建相同内容的 String 对象时都会是反射改变后的值,这时候在后面的代码逻辑执行时就会出现让你 “摸不着头脑” 的现象,具有迷惑性,出了奇葩的问题你也很难排除到原因。后面在 代码4处 我们会验证这个问题。

先来看看如何通过反射改变 String 对象的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class WhyStringImutableTest {
public static void main(String[] args) {
String str = new String("123");
System.out.println("反射前 str:"+str);
try {
Field field = String.class.getDeclaredField("value");
field.setAccessible(true);
char[] aa = (char[]) field.get(str);
aa[1] = '1';
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
}
System.out.println("反射后 str:"+str);
}

打印结果:

1
2
反射前 str:123
反射后 str:113 // 可见,反射后,str 的值确实改变了

代码4:通过反射改变String 对象的值造成的后果

下面我们来验证因为一旦通过反射改变对应的 String 对象的值,后面再创建相同内容的 String 对象时都会是反射改变后的值的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class WhyStringImutableTest {
public static void main(String[] args) {
String str = new String("123");
System.out.println("反射前 str:"+str);
try {
Field field = String.class.getDeclaredField("value");
field.setAccessible(true);
char[] aa = (char[]) field.get(str);
aa[1] = '1';
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
}
System.out.println("反射后 str:"+str);

String str2 = new String("123");
System.out.println("str2:"+str2); // 我们来看 str2 会输出什么,会输出 113?
System.out.println("判断是否是同一对象:"+str == str2); // 判断 str 和 str2 的内存地址值是否相等
System.out.println("判断内容是否相同:"+str.equals(str2)); // 判断 str 和 str2 的内容是否相等
}

执行结果如下:

1
2
3
4
5
反射前 str:123
反射后 str:113
str2:113 // 竟然不是123??而是输出113,说明 str2 也是反射修改后的值。
判断是否是同一对象:false // 输出 false,说明在内存中确实创建了两个不同的对象
判断内容是否相同:true // 输出true,说明依然判断为两个对象内容是相等的

由上面的输出结果,我们可知,反射后再新建相同内容的字符串对象时会是反射修改后的值,这就造成了很大迷惑性,在实际开发中要谨慎这么做。


本文整理自

为什么Java中String是不可变的

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!