Be Better

厚积薄发


  • 首页

  • 标签

  • 分类

  • 归档

  • Sitemap

  • 搜索

负载均衡

发表于 2018-05-11 | 分类于 架构 | 阅读次数:

一. 方法

1. HTTP重定向负载均衡

将http请求通过302的临时重定向给实际服务器

  • 优点: 简单
  • 缺点: 需要请求量词服务器,性能差; 重定向服务器称为瓶颈;可能被判断为seo作弊,降低搜索排名

2. DNS域名解析负载均衡

在DNS服务器中进行负载均衡算法,分配相应的应用服务器

  • 优点: 将负载均衡工作转交给DNS,DNS本身支持就近分配
  • 缺点: DNS多级解析,下线某台服务器不能及时更新;控制权在域名服务商,无法做跟多的改善和管理

  • 实际: 使用DNS作为一级负载均衡,得到内部的负载均衡服务器,再通过内部负载均衡服务器进行web服务器的分配。

3. 反向代理负载均衡

  • 优点: 属于七层负载均衡,和反向代理服务器功能集成在一起部署简单
  • 缺点: 反向代理服务器是所有请求和相应的中转站,可能成为瓶颈

4. IP负载均衡

  • 修改数据包而不是转发,在负载均衡服务器将目的地址改成web服务器,响应数据包再回到负载均衡服务器,在负载均衡服务器将数据包源地址修改为自身
  • 优点: 在内核进程完成数据分发性能更好
  • 缺点: 集群的最大响应数据吞吐量不得不受制于负载均衡服务器网卡宽带

5. 数据链路层负载均衡

  • 三角传输模式,不修改IP地址,只修改mac地址,通过配置真是物理服务器所有机器虚拟IP和负载均衡服务器IP地址一样,使得不修改数据包源地址和目的地址就可以进行数据分发。
  • 优点: 不需要负载均衡服务器进行地址转换,可直接将响应数据包直接返回给用户浏览器,这种方式又叫直接路由方式DR

二. 常见负载均衡算法

  1. 轮询

  2. 加权轮询

    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
    44
    45
    public class IpMap {
    /**
    * key: Ip
    * Value: 权重
    */
    public static LinkedHashMap<String, Integer> serverWeightMap = new LinkedHashMap<>();
    static {
    serverWeightMap.put("192.168.1.100", 1);
    serverWeightMap.put("192.168.1.101", 1);
    serverWeightMap.put("192.168.1.102", 4);
    serverWeightMap.put("192.168.1.103", 1);
    serverWeightMap.put("192.168.1.104", 1);
    serverWeightMap.put("192.168.1.105", 3);
    serverWeightMap.put("192.168.1.106", 1);
    serverWeightMap.put("192.168.1.107", 2);
    serverWeightMap.put("192.168.1.108", 1);
    serverWeightMap.put("192.168.1.109", 1);
    }
    }


    public static String getServerByAddWeightRoundRobin() {
    Map<String, Integer> serverMap = new LinkedHashMap<>();
    //IpMap为web服务器集合
    serverMap.putAll(IpMap.serverWeightMap);
    Set<String> keySet = serverMap.keySet();
    Iterator<String> iterator = keySet.iterator();

    List<String> serverList = new ArrayList<String>();
    while (iterator.hasNext()) {
    String server = iterator.next();
    int weight = serverMap.get(server);
    for (int i = 0; i < weight; i++)
    serverList.add(server);
    }
    String server = null;
    synchronized (pos) {
    if (pos == keySet.size()) {
    pos = 0;
    }
    server = serverList.get(pos);
    pos++;
    }
    return server;
    }
  3. 随机

  4. 最少连接

记录每个应用服务器正在处理的连接数,将新到的请求分发到最少连接服务器上。

  1. 源地址散列(一致性hash)

三. 一致性hash

一致性哈希算法是分布式系统中常用的算法。比如,一个分布式的存储系统,要将数据存储到具体的节点上,
如果采用普通的hash方法,将数据映射到具体的节点上,如key%N,key是数据的key,N是机器节点数,
如果有一个机器加入或退出这个集群,则所有的数据映射都无效了。

一致性哈希算法解决了普通余数Hash算法伸缩性差的问题,可以保证在上线、下线服务器的情况下尽量有多的请求命中原来路由到的服务器。

1.原理

(1). 环形Hash空间

按照常用的hash算法来将对应的key哈希到一个具有2^32次方个桶的空间中,即0~(2^32)-1的数字空间中。

现在我们可以将这些数字头尾相连,想象成一个闭合的环形。如下图

(2). 把数据通过一定的hash算法处理后映射到环上

现在我们将object1、object2、object3、object4四个对象通过特定的Hash函数计算出对应的key值,然后散列到Hash环上。如下图:

Hash(object1) = key1;
Hash(object2) = key2;
Hash(object3) = key3;
Hash(object4) = key4;

(3). 将机器通过hash算法映射到环上

在采用一致性哈希算法的分布式集群中将新的机器加入,其原理是通过使用与对象存储一样的Hash算法将机器也映射到环中

(一般情况下对机器的hash计算是采用机器的IP或者机器唯一的别名作为输入值),然后以顺时针的方向计算,将所有对象存储到离自己最近的机器中。

假设现在有NODE1,NODE2,NODE3三台机器,通过Hash算法得到对应的KEY值,映射到环中,其示意图如下:

Hash(NODE1) = KEY1;
Hash(NODE2) = KEY2;
Hash(NODE3) = KEY3;

通过上图可以看出对象与机器处于同一哈希空间中,这样按顺时针转动object1存储到了NODE1中,object3存储到了NODE2中,object2、object4存储到了NODE3中。

在这样的部署环境中,hash环是不会变更的,因此,通过算出对象的hash值就能快速的定位到对应的机器中,这样就能找到对象真正的存储位置了。

2.机器的删除与添加

普通hash求余算法最为不妥的地方就是在有机器的添加或者删除之后会造成大量的对象存储位置失效。下面来分析一下一致性哈希算法是如何处理的。

(1). 节点(机器)的删除

以上面的分布为例,如果NODE2出现故障被删除了,那么按照顺时针迁移的方法,object3将会被迁移到NODE3中,这样仅仅是object3的映射位置发生了变化,其它的对象没有任何的改动。如下图:

(2). 节点(机器)的添加

如果往集群中添加一个新的节点NODE4,通过对应的哈希算法得到KEY4,并映射到环中,如下图:

通过按顺时针迁移的规则,那么object2被迁移到了NODE4中,其它对象还保持着原有的存储位置。
通过对节点的添加和删除的分析,一致性哈希算法在保持了单调性的同时,还是数据的迁移达到了最小,这样的算法对分布式集群来说是非常合适的,避免了大量数据迁移,减小了服务器的的压力。

3.平衡性–虚拟节点

根据上面的图解分析,一致性哈希算法满足了单调性和负载均衡的特性以及一般hash算法的分散性,但这还并不能当做其被广泛应用的原由,

因为还缺少了平衡性。下面将分析一致性哈希算法是如何满足平衡性的。

hash算法是不保证平衡的,如上面只部署了NODE1和NODE3的情况(NODE2被删除的图),object1存储到了NODE1中,而object2、object3、object4都存储到了NODE3中,这样就造成了非常不平衡的状态。在一致性哈希算法中,为了尽可能的满足平衡性,其引入了虚拟节点。

——“虚拟节点”( virtual node )是实际节点(机器)在 hash 空间的复制品( replica ),一个实际节点(机器)对应了若干个“虚拟节点”,这个对应个数也成为“复制个数”,“虚拟节点”在 hash 空间中以hash值排列。

以上面只部署了NODE1和NODE3的情况(NODE2被删除的图)为例,之前的对象在机器上的分布很不均衡,现在我们以2个副本(复制个数)为例,这样整个hash环中就存在了4个虚拟节点,最后对象映射的关系图如下:

根据上图可知对象的映射关系:object1->NODE1-1,object2->NODE1-2,object3->NODE3-2,object4->NODE3-1。通过虚拟节点的引入,对象的分布就比较均衡了。那么在实际操作中,正真的对象查询是如何工作的呢?对象从hash到虚拟节点到实际节点的转换如下图:

“虚拟节点”的hash计算可以采用对应节点的IP地址加数字后缀的方式。例如假设NODE1的IP地址为192.168.1.100。引入“虚拟节点”前,计算 cache A 的 hash 值:
Hash(“192.168.1.100”);
引入“虚拟节点”后,计算“虚拟节”点NODE1-1和NODE1-2的hash值:
Hash(“192.168.1.100#1”); // NODE1-1
Hash(“192.168.1.100#2”); // NODE1-2

4.实现

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
public class LoadBalancer<T> {
private static Integer pos = 0;
private static MessageDigest md5 = null;
private static int numberOfReplicas;
private SortedMap<String, T> circle = new TreeMap<>();
/**
* @param numberOfReplicas, 虚拟结点个数
* @param nodes, 实际结点集合
*/
public LoadBalancer(int numberOfReplicas, Collection<T> nodes) {
this.numberOfReplicas = numberOfReplicas;
for (T node: nodes) {
add(node);
}
}
/**
* @param node
*
* 为每个加入的实际结点添加虚拟结点
*/
public void add(T node) {
for (int i = 0; i < numberOfReplicas; i++) {
//key:虚拟结点的string,用类似node1:1, node1:2, node1:3类似用这样的分隔方式设置虚拟节点
//value:实际结点
circle.put(hash(node.toString() + i), node);
}
}
/**
* @param node
* 删除相应实际结点对应的虚拟结点
*/
public void remove(T node) {
for (int i = 0; i < numberOfReplicas; i++) {
circle.remove(hash(node.toString() + i));
}
}

/**
* @param key
* @return 返回该key结点将要存储的实际结点
*/
public T getRealNode(Object key) {
if (circle.isEmpty()) {
return null;
}
String hash = hash(key.toString());
if (!circle.containsKey(hash)) {
SortedMap<String, T> tailMap = circle.tailMap(hash);
hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
}
return circle.get(hash);
}
public long getSize() {
return circle.size();
}
public static String hash(String key) {
if (md5 == null) {
try {
md5 = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("no md5 algrithm found");
}
}
md5.reset();
md5.update(key.getBytes());
byte[] bKey = md5.digest();

// 将md5摘要转成long格式
long result = ((long) (bKey[3] & 0xFF) << 24)
| ((long) (bKey[2] & 0xFF) << 16
| ((long) (bKey[1] & 0xFF) << 8)
| (long) (bKey[0] & 0xFF));
return String.valueOf(result & 0xffffffffL);
}



public static String getServerByAddWeightRoundRobin() {
Map<String, Integer> serverMap = new LinkedHashMap<>();
serverMap.putAll(IpMap.serverWeightMap);
Set<String> keySet = serverMap.keySet();
Iterator<String> iterator = keySet.iterator();

List<String> serverList = new ArrayList<String>();
while (iterator.hasNext()) {
String server = iterator.next();
int weight = serverMap.get(server);
for (int i = 0; i < weight; i++)
serverList.add(server);
}
String server = null;
synchronized (pos) {
if (pos == keySet.size()) {
pos = 0;
}
server = serverList.get(pos);
pos++;
}
return server;
}


public static void main(String[] args) throws NoSuchAlgorithmException {
Set<String> nodes = new HashSet<>();
nodes.add("A");
nodes.add("B");
nodes.add("C");
nodes.add("D");
nodes.add("E");
LoadBalancer<String> lb = new LoadBalancer<>(100, nodes);
Map<String, Integer> count = new HashMap<>();
for (int i = 0; i < 10000000; i++) {
String real = lb.getRealNode(i + "");
count.put(real, count.getOrDefault(real, 0) + 1);
}
for (String i: count.keySet()) {
System.out.println(count.get(i));
}
}

}

页面级去重-文本相似度计算

发表于 2018-04-16 | 分类于 机器学习 | 阅读次数:

一. 前言

很久没写博客了,这段时间事情实在是多,很多看到的新知识只能草草的记下来没时间整理。今天下午面完阿里爸爸二面,晚上应该不会再突击我了,趁热把下午遇到的一个没解决的问题记录一下。

在爬虫去重这个问题上,单纯使用md5这样的完全比较hash算法不是很精确,因为不可能两个网页完全一样,都会有些稍微的更改,这样我们就需要记录并计算两篇文章的相似性进行查重。文章相似性计算应该属于NLP中的内容,由于我是个机器学习小白,所以我尽量使用工程化的思路来解决这个问题。

二. 最小编辑距离

这种方法可能是最简单的方法了,编辑距离(Edit Distance),又称Levenshtein距离,是指两个字串之间,由一个转成另一个所需的最少编辑操作次数。许可的编辑操作包括:

  1. 将一个字符替换成另一个字符
  2. 插入一个字符
  3. 删除一个字符。

一般来说,编辑距离越小,两个串的相似度越大。然后拿编辑距离去除以两者之间的最大长度,意味着只要变动这么多就可以从A变成B,所以不用变动的字符便代表了相似度。这种方法实现比较简单,在<编程之美>上也有过介绍,主要是通过递归来实现。

两个字符串A,B的编辑距离最大是他们的长度之和,每次操作之后可能有如下6种情况:

  1. 删除A的第一个字符计算A[2….lenA],B[1….lenB]
  2. 删除B的第一个字符计算A[1….lenA],B[2….lenB]
  3. 修改A的第一个字符计算A[2….lenA],B[2….lenB]
  4. 修改B的第一个字符计算A[2….lenA],B[2….lenB]
  5. 插入A的第一个字符到B计算A[1….lenA],B[2….lenB]
  6. 插入B的第一个字符到A计算A[2….lenA],B[1….lenB]

我们可以将上述的6中情况合并成3种:

  1. 一步操作后计算A[2….lenA],B[1….lenB]
  2. 一步操作后计算A[1….lenA],B[2….lenB]
  3. 一步操作后计算A[2….lenA],B[2….lenB]

然后每次获得三者中最小的一个再+1进行迭代即可。这种方法最简单也是最容易实现,但是可能每来一篇文章都需要和文档库中的文章进行比较,而且文章过长的话,递归深度也很大。

三. simhash

1. TF-IDF

TF-IDF是一种统计方法,用以评估一字词对于一个文件集或一个语料库中的其中一份文件的重要程度。字词的重要性随着它在文件中出现的次数成正比增加,但同时会随着它在语料库中出现的频率成反比下降。TF-IDF加权的各种形式常被搜索引擎应用,作为文件与用户查询之间相关程度的度量或评级。除了TF-IDF以外,因特网上的搜索引擎还会使用基于链接分析的评级方法,以确定文件在搜寻结果中出现的顺序。

这是摘自百度百科的解释,简而言之就是一个词语在一篇文章中出现的次数越多说明他越重要,一个词在文档库中出现的次数越少说明他越有代表性,利用这两个值进行一个权值相乘的计算,就能代表一个词语对于一个文章的重要程度。

2. simhash的实现

simhash是谷歌发明的算法,据说很nb,可以将一个文档转换成64位的字节,然后我们可以通过判断两个字节的汉明距离就知道是否相似了。

在信息论中,两个等长字符串之间的汉明距离是两个字符串对应位置的不同字符的个数。换句话说,它就是将一个字符串变换成另外一个字符串所需要替换的字符个数。例如:

1011101 与 1001001 之间的汉明距离是 2。

首先我们来计算SimHash:

  1. 提取文档关键词得到[word,weight]这个一个数组。(举例 [美国,4])
  2. 用hash算法将word转为固定长度的二进制值的字符串[hash(word),weight]。(举例 [100101,4])
  3. word的hash从左到右与权重相乘,如果为1则乘以1 ,如果是0则曾以-1。(举例4,-4,-4,4,-4,4)
  4. 接着计算下个数,直到将所有分词得出的词计算完,然后将每个词第三步得出的数组中的每一个值相加。(举例美国和51区,[4,-4,-4,4,-4,4]和[5 -5 5 -5 5 5]得到[9 -9 1 -1 1 9])
  5. 对第四步得到的数组中每一个值进行判断,如果>0记为1,如果<0记为0。(举例[101011])


第四步得出的就是这个文档的SimHash。

这样我们就能将两个不同长度的文档转换为同样长度的SimHash值,so,我们现在可以计算第一个文档的值和第二个文档的汉明距离(一般<3就是相似度高的)。

SimHash本质上是局部敏感性的hash(如果是两个相似的句子,那么只会有部分不同),和md5之类的不一样。 正因为它的局部敏感性,所以我们可以使用海明距离来衡量SimHash值的相似度。

如果想要小数形式的可以这么做:1 - 汉明距离 / 最长关键词数组长度。

引用

<编程之美>
https://blog.csdn.net/chinafire525/article/details/78686876

同步异步和阻塞非阻塞的区别

发表于 2018-03-10 | 分类于 计算机基础 | 阅读次数:

吐槽

早上五点半胃疼醒了,去医院噼里啪啦检查了一天花了1500啥也没检查出来,继续疼。在排队的时候刷到知乎上的这个问题感觉似懂非懂,这里总结一下看到的几个比较好的解释。

一. I/O模型

  • 阻塞式I/O
  • 非阻塞式I/O
  • I/O复用(select,poll,epoll…)
  • 信号驱动式I/O(SIGIO)
  • 异步I/O(POSIX的aio_系列函数)

一个输入操作一般有两个不同的阶段:

  1. 等待数据准备好
  2. 从内核到进程拷贝数据

对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所有等待分组到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用程序缓冲区。

1. 阻塞式I/O模型

默认情况下,所有套接字都是阻塞的。下面我们以阻塞套接字的recvfrom的的调用图来说明阻塞。 标红的这部分过程就是阻塞,直到阻塞结束recvfrom才能返回。

2.非阻塞式I/O

以下这句话很重要:进程把一个套接字设置成非阻塞是在通知内核,当所请求的I/O操作非得把本进程投入睡眠才能完成时,不要把进程投入睡眠,而是返回一个错误。看看非阻塞的套接字的recvfrom操作如何进行,可以看出recvfrom总是立即返回。

3.I/O多路复用

虽然I/O多路复用的函数也是阻塞的,但是其与以上两种还是有不同的,I/O多路复用是阻塞在select,epoll这样的系统调用之上,而没有阻塞在真正的I/O系统调用如recvfrom之上。

4.信号驱动式I/O

见得少,直接给出原图

5. 异步I/O

这类函数的工作机制是告知内核启动某个操作,并让内核在整个操作(包括将数据从内核拷贝到用户空间)完成后通知我们。如图所示,注意红线标记处说明在调用时就可以立马返回,等函数操作完成会通知我们。

区别

前四种I/O模型都是同步I/O操作,他们的区别在于第一阶段,而他们的第二阶段是一样的:在数据从内核复制到应用缓冲区期间(用户空间),进程阻塞于recvfrom调用。相反,异步I/O模型在这两个阶段都要处理。书上也有一副很好的图解释了

  • 同步I/O操作:导致请求进程阻塞,直到I/O操作完成
  • 异步I/O操作:不导致请求进程阻塞

阻塞,非阻塞:进程/线程要访问的数据是否就绪,进程/线程是否需要等待;

同步,异步:访问数据的方式,同步需要主动读写数据,在读写数据的过程中还是会阻塞;异步只需要I/O操作完成的通知,并不主动读写数据,由操作系统内核完成数据的读写。

这里有个来自知乎的例子:
老张爱喝茶,废话不说,煮开水。出场人物:老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。

  1. 老张把水壶放到火上,立等水开。(同步阻塞)老张觉得自己有点傻
  2. 老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞)老张还是觉得自己有点傻,于是变高端了,买了把会响笛的那种水壶。水开之后,能大声发出嘀~~~~的噪音。
  3. 老张把响水壶放到火上,立等水开。(异步阻塞)老张觉得这样傻等意义不大
  4. 老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞)

老张觉得自己聪明了。所谓同步异步,只是对于水壶而言。普通水壶,同步;响水壶,异步。虽然都能干活,但响水壶可以在自己完工之后,提示老张水开了。这是普通水壶所不能及的。同步只能让调用者去轮询自己(情况2中),造成老张效率的低下。所谓阻塞非阻塞,仅仅对于老张而言。立等的老张(被挂起),阻塞;看电视的老张,非阻塞。情况1和情况3中老张就是阻塞的,媳妇喊他都不知道。虽然3中响水壶是异步的,可对于立等的老张没有太大的意义。所以一般异步是配合非阻塞使用的,这样才能发挥异步的效用。

二. IO复用

这里一开始可能看不出来IO复用比之前阻塞IO有哪些好处,这里也给出一个在知乎上看的解释:

假设你是一个机场的空管, 你需要管理到你机场的所有的航线, 包括进港,出港, 有些航班需要放到停机坪等待,有些航班需要去登机口接乘客。
你会怎么做?
最简单的做法,就是你去招一大批空管员,然后每人盯一架飞机, 从进港,接客,排位,出港,航线监控,直至交接给下一个空港,全程监控。
那么问题就来了:
很快你就发现空管塔里面聚集起来一大票的空管员,交通稍微繁忙一点,新的空管员就已经挤不进来了。 空管员之间需要协调,屋子里面就1, 2个人的时候还好,几十号人以后 ,基本上就成菜市场了。空管员经常需要更新一些公用的东西,比如起飞显示屏,比如下一个小时后的出港排期,最后你会很惊奇的发现,每个人的时间最后都花在了抢这些资源上。 现实上我们的空管同时管几十架飞机稀松平常的事情, 他们怎么做的呢?
他们用这个东西

这个东西叫flight progress strip. 每一个块代表一个航班,不同的槽代表不同的状态,然后一个空管员可以管理一组这样的块(一组航班),而他的工作,就是在航班信息有新的更新的时候,把对应的块放到不同的槽子里面。这个东西现在还没有淘汰哦,只是变成电子的了而已。。是不是觉得一下子效率高了很多,一个空管塔里可以调度的航线可以是前一种方法的几倍到几十倍。
如果你把每一个航线当成一个Sock(I/O 流), 空管当成你的服务端Sock管理代码的话.第一种方法就是最传统的多进程并发模型 (每进来一个新的I/O流会分配一个新的进程管理。)第二种方法就是I/O多路复用 (单个线程,通过记录跟踪每个I/O流(sock)的状态,来同时管理多个I/O流 。)其实“I/O多路复用”这个坑爹翻译可能是这个概念在中文里面如此难理解的原因。所谓的I/O多路复用在英文中其实叫 I/O multiplexing. 如果你搜索multiplexing啥意思,基本上都会出这个图:

于是大部分人都直接联想到”一根网线,多个sock复用” 这个概念,包括上面的几个回答, 其实不管你用多进程还是I/O多路复用, 网线都只有一根好伐。多个Sock复用一根网线这个功能是在内核+驱动层实现的。
重要的事情再说一遍: I/O multiplexing 这里面的 multiplexing 指的其实是在单个线程通过记录跟踪每一个Sock(I/O流)的状态(对应空管塔里面的Fight progress strip槽)来同时管理多个I/O流. 发明它的原因,是尽量多的提高服务器的吞吐能力。 是不是听起来好拗口,看个图就懂了

在同一个线程里面, 通过拨开关的方式,来同时传输多个I/O流。

  • 顺便提一句:
  1. select和poll都只返回可用集合,线程不安全,区别是select有1024个连接的限制
  2. epoll 现在是线程安全的,返回具体可用的连接,现在不仅告诉你sock组里面数据,还会告诉你具体哪个sock有数据,你不用自己去找了。

引用

https://www.zhihu.com/question/19732473
UNIX网络编程(卷一)
https://www.zhihu.com/question/32163005

类加载机制

发表于 2018-03-06 | 分类于 jvm | 阅读次数:

一. 虚拟机类加载机制

1. 概述:

把描述类的数据从class文件加载到内存,对数据进行校验、转换和初始化,最终形成可被虚拟机使用的java类型。

2. 过程

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班的“开始”(仅仅指的是开始,而非执行或者结束,因为这些阶段通常都是互相交叉的混合进行,通常会在一个阶段执行的过程中调用或者激活另一个阶段),而解析阶段则不一定(它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。

验证、准备、解析称为连接Linking阶段。

加载 验证 准备 初始化 卸载 需要按顺序开始

3. 初始化

有且只有5种情况必须立刻初始化的:

  1. 使用new实例化对象,读取或设置一个类的静态字段,调用静态方法(不包括常量)
  2. 使用java.lang.reflect包对类进行反射调用,如果没有进行类初始化,则需要先触发其初始化
  3. 当初始化一个类的时候,如果发现其父类还没初始化,要先触发其父类的初始化
  4. 虚拟机启动时,需要指定一个要执行的主类
  5. java7动态语言支持,一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、 REF_putStatic、 REF_invokeStatic方法句柄,并且方法句柄对应的类没有进行过初始化,需要先触发其初始化。(没用过不是很理解)

以上五种引用类的方法称为主动引用,除此之外都是被动引用。
比如:

  1. 通过子类调用父类的静态字段。
  2. 通过数组定义来引用类

接口初始化

接口的加载过程与类的加载过程稍有不同。接口中不能使用static{}块。当一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有真正在使用到父接口时(例如引用接口中定义的常量)才会初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
class superClass{
static {
System.out.println("123");
}
}

public class Demo {

public static void main(String[] args) {
superClass[] a = new superClass[2];
}
}
///会触发一个superClass一维数组类的初始化
  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
package org.fenixsoft.classloading;

/**
* 被动使用类字段演示三:
* 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
**/
public class ConstClass {

static {
System.out.println("ConstClass init!");
}

public static final String HELLOWORLD = "hello world";
}

/**
* 非主动使用类字段演示
**/
public class NotInitialization {

public static void main(String[] args) {
System.out.println(ConstClass.HELLOWORLD);
}
}

PS:接口的不同,在于第三种,不要求其父接口全部完成了初始化,只有在使用的时候才会初始化。

二. 类加载过程

1. 加载

  1. 通过一个全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成代表这个类的java.lang.Class对象

加载阶段即可以使用系统提供的类加载器在完成,也可以由用户自定义的类加载器来完成。加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始。

总结

就是加载类的二进制资源,转为相应的方法区数据结构生成Class对象

2. 验证(连接阶段)

确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机。

  1. 文件格式验证

验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。

  1. 元数据验证

对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。

  1. 字节码验证

主要工作是进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为

  1. 符号引用验证

发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在“解析阶段”中发生。
使用-Xverify:none关闭大部分反复使用的代码验证

3. 准备(连接阶段)

正式为类变量分配内存并设置类变量的初始值。仅仅包括类变量(static)不包括实例变量,
这里的初始值是0,而不是实际值。

1
public static int value = 123;

value=123在类构造器的方法中,在初始化阶段才会执行。
若在字段属性表中存在ConstantValue属性,那么在准备阶段就会初始化为所指定的值

1
public staic final int value = 123;

在准备阶段就会将value赋值为123

4. 解析(连接阶段)

将常量池符号引用替换为直接引用

  • 符号引用:内存布局无关,不一定已经加载到内存中
  • 直接引用:内存布局相关,如果有那么一定在内存中存在

符号引用:

  1. 类和接口的全限定名
  2. 字段的名称和描述符
  3. 方法的名称和描述符

5. 初始化

真正开始执行类中定义的java代码,初始化阶段执行类构造器
自动收集类变量的赋值和静态语句块,按照出现顺序决定,优先执行父类

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

static {
i = 20;//可以赋值
System.out.println(i);//不能引用
}
static int i = 10;

public static void main(String[] args) {
System.out.println(Demo.i);

}
}

加载顺序可以参考这个图

三. 类加载器(热部署)

只有同一个类加载器加载出来的类,才是真正意义上的相等。

  1. 启动类加载器bootstrap
    负责将存放在\lib目录或者-Xbootclasspath参数所指定的路径中的类库加载到虚拟机内存中。
  2. 扩展类加载器extension
    \lib\ext目录or java.ext.dirs系统变量指定路径的类库

  3. 应用程序类加载器application(系统类加载器)
    加载classpath指定类库

    1
    2
    3
    4
    5
    graph BT
    扩展类加载器-->启动类加载器
    应用程序类加载器-->扩展类加载器
    自定义类加载器-->应用程序类加载器
    自定义类加载器-->应用程序类加载器

双亲委派模型

除了启动类加载器外,所有的类加载器都有自己的父类加载器,而且是通过组合而不是继承的关系来实现。这样可以保证应用程序的稳定。比如所有的java.lang.object都是通过启动类加载器加载rt.jar中的object实现的

破坏双亲委派模型实现热部署

每一个程序模块都有一个自己的类加载器,当需要更换一个模块时,就把模块连同类加载器一起换掉以实现代码的热替换。

GC和内存分配策略

发表于 2018-03-05 | 分类于 jvm | 阅读次数:

一. 概述

由于程序计数器、虚拟机栈和本地方法栈都是跟线程相关的,栈中的栈帧随着方法的进入和退出进行着出栈和入栈的操作,当方法结束的时候,这部分内存也会跟着回收,所以一般gc讨论的都是方法区和堆。

GC对象的判断

1. 引用计数法

给对象添加一个计数器,有人引用就加1,引用失效就减1,任何时刻计数器为0的对象就不可能再被使用了。

2. 可达性分析算法

判断当前对象与GC Root是否可达

可作为GC Roots的对象:

  1. 虚拟机栈(栈帧中的本地变量表)中的引用对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI(Native方法)引用的对象

说的通俗一点就是:方法运行时,方法中引用的对象;类的静态变量引用的对象;类中常量引用的对象;Native方法中引用的对象。在网上看老外说的更具体分成了下面这些:

  1. System Class

Class loaded by bootstrap/system class loader. For example, everything from the rt.jar like java.util.* .

  1. JNI Local

Local variable in native code, such as user defined JNI code or JVM internal code.

  1. JNI Global

Global variable in native code, such as user defined JNI code or JVM internal code.

  1. Thread Block

Object referred to from a currently active thread block.

  1. Thread

A started, but not stopped, thread.

  1. Busy Monitor

Everything that has called wait() or notify() or that is synchronized. For example, by calling synchronized(Object) or by entering a synchronized method. Static method means class, non-static method means object.

  1. Java Local

Local variable. For example, input parameters or locally created objects of methods that are still in the stack of a thread.

  1. Native Stack

In or out parameters in native code, such as user defined JNI code or JVM internal code. This is often the case as many methods have native parts and the objects handled as method parameters become GC roots. For example, parameters used for file/network I/O methods or reflection.

  1. Finalizable

An object which is in a queue awaiting its finalizer to be run.

  1. Unfinalized

An object which has a finalize method, but has not been finalized and is not yet on the finalizer queue.

  1. Unreachable

An object which is unreachable from any other root, but has been marked as a root by MAT to retain objects which otherwise would not be included in the analysis.

  1. Java Stack Frame

A Java stack frame, holding local variables. Only generated when the dump is parsed with the preference set to treat Java stack frames as objects.

13.Unknown

An object of unknown root type. Some dumps, such as IBM Portable Heap Dump files, do not have root information. For these dumps the MAT parser marks objects which are have no inbound references or are unreachable from any other root as roots of this type. This ensures that MAT retains all the objects in the dump.

3. 引用的分类

在java1.2之后对引用的概念进行了扩充,有了4种引用

  1. 强引用:程序代码中普遍存在的类似
    1
    Object o = new Object();

只要强引用还在,GC就不会回收掉被引用的对象。

  1. 软引用: 描述一些还有用但是非必须的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果此时内存还不够才会oom

如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

  1. 弱引用: 也是描述非必须对象,强度比弱引用还要弱,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当gc工作无论当时内存是否足够都会回收掉只被弱引用关联的对象。
  2. 虚引用: 最弱的引用关系。一个对象是否有虚引用完全不会对其生存周期构成影响,也无法通过虚引用来获取一个对象实例。设置他的目的就是在被GC回收的时候收到一个系统通知

4. 可达性分析

若一个对象的引用类型有多个,那到底如何判断它的可达性呢?其实规则如下:

单条引用链的可达性以最弱的一个引用类型来决定;
多条引用链的可达性以最强的一个引用类型来决定;

我们假设图2中引用①和③为强引用,⑤为软引用,⑦为弱引用,对于对象5按照这两个判断原则,路径①-⑤取最弱的引用⑤,因此该路径对对象5的引用为软引用。同样,③-⑦为弱引用。在这两条路径之间取最强的引用,于是对象5是一个软可及对象

5. 回收方法区

废弃常量和无用类的回收

回收废弃常量类似回收对象,比如一个字符串常量,没有被任何String对象使用,如果这时发生了内存回收,而且有必要的话,会被系统清理出常量池

废弃类的判断要同时满足下面3个条件:

  1. 该类的所有实例都已经被回收,也就是Java堆中不存在该类的任何实例
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射方位该类的方法

在java8中由于永久代的废弃,类信息保存在本地内存中,所以废弃类导致oom溢出的情况得到了很大的改善。

二. 垃圾收集算法

1. 标记清除算法

对象真正死亡要经历2次标记过程

  1. 对象在进行可达性分析后,没有与GC ROOTS相连接的引用链,那么它会被第一次标记,并进行一次筛选,筛选条件是此对象是否有必要执行的finalize()方法。以下是2种没有必要执行finalize()方法的情形。
    1. 对象没有覆盖finalize()
    2. finalize()方法已经被虚拟机调用过。因为finalize()方法只会被虚拟机调用一次。

如果要执行finalize()方法,则会将对象加入F——QUEUE的队列中,并且会在稍后由jvm自动建立的、低优先级的Finalizer线程去执行。这里的执行只是会去触发这个方法并不能承诺会等待方法运行结束。因为如果一个对象在finalize()方法中执行缓慢,或者发生了死循环,会导致F-Queue中其他对象处于永久等待,甚至导致整个内存回收系统崩溃。对象可以在finalize()方法中重新和引用链建立关联,这样就可以逃脱回收。

  1. GC对F-Queue中的对象进行第二次小规模的标记。也就是说如果没有执行finalize()方法进入F-Queue那么直接就被删除了。

    finalize()是对象逃脱死亡的最后机会,但是finalize()是对c++的妥协,他的运行代价高,不确定性大,使用try——finally能更好的完成他需要执行的工作,所以不推荐使用它。

缺点:

  1. 效率问题,标记和清楚2个过程的效率都不高
  2. 空间问题,标记清除之后会长生大量的不连续碎片,碎片太多在分配较大对象的时候,无法找到足够的连续内存不得不触发领一次垃圾收集动作

2. 复制算法

复制算法(新生代 GC)

基于大多数新生对象(98%)都会在GC中被收回的假设。新生代的GC 使用复制算法。

在GC前To 幸存区(survivor)保持清空,对象保存在 Eden 和 From 幸存区(survivor)中,GC运行时,Eden中的幸存对象被复制到 To 幸存区(survivor)。针对 From 幸存区(survivor)中的幸存对象,会考虑对象年龄,如果年龄没达到阀值(tenuring threshold),对象会被复制到To 幸存区(survivor)。如果达到阀值对象被复制到老年代。复制阶段完成后,Eden 和From 幸存区中只保存死对象,可以视为清空。如果在复制过程中To 幸存区被填满了,剩余的对象会被复制到老年代中。最后 From 幸存区和 To幸存区会调换下名字,在下次GC时,To 幸存区会成为From 幸存区。当survivor空间不够的时候会进行分配担保,把对象保存到老年代。

  • 优点:运行高效,实现简单
  • 缺点:会浪费一定的内存,对象存活率较高会产生过多的复制

  • 上图演示GC过程,黄色表示死对象,绿色表示剩余空间,红色表示幸存对象

3. 标记-整理算法(老年代)

由于老年代的对象存活时间久,使用复制算法将进行大量复制操作,效率很低,而且其存在时间久,很可能出现大面积的存活对象,这样在极端情况下100%内存的对象都存活,就还需要额外的空间分配担保。

标记-整理算法类似之前的标记清楚算法,但是他不会直接清除可回收对象,而是将存活对象都移动到一段,再直接清理边界以外的内存。

4. HostSpot的实现

1. 枚举GCRoot

通过OopMap数据结构,在类加载的时候,就把对象内什么偏移量上是什么类型的数据计算出来了。

2. 安全点

会导致OopMap内容变化的指令非常多,所以只会在特定的位置记录这些信息,这样的位置称为安全点。 比如:方法调用、循环跳转、异常跳转等。

在这个点, 所有GC Root的状态都是已知并且heap里的对象是一致的; 在这个点进行GC时, 所有的线程都需要block住, 这就是(STW)Stop The World.

在安全点终端所有线程的两种方法

  1. 抢先式中断:所有线程中断,如果有的线程不在安全点上就恢复它,让其执行到安全点再中断,但是几乎没有jvm采用这种方式。
  2. 主动式中断:在和安全点重合的地方设置一个轮询标志,让线程执行的时候主动去轮询这个标志,如果中断标志为真,就自己中断挂起。

    3. 安全区域safe region

    针对于处于Sleep状态或者blocked状态的线程,指的是在一段代码片段之中引用关系不会发生变化,在这片区域的任何地方GC都是安全的。
    当线程执行到安全区域的时候首先会标识自己进入了安全区域,这样在这段时间内都可以进行gc,当要离开安全区域时,会检查是否已经完成了gcroot的枚举,如果完成了就继续执行,否则就等待。

5. 垃圾收集器


连线代表可搭配使用

1.Serial收集器(Client模式默认新生代收集器,复制算法,单线程)

在进行垃圾回收的时候,暂停所有正在工作的线程,直到结束。
优点:简单高效。

2. ParNew收集器(新生代,复制,多线程)

ParNew就是Serial的多线程版本,除了多线程外几乎一致。Server模式下的首选新生代收集器,只有他和Serial能和cms配合。使用-XX:+UseParNewGC开关来控制,使用-XX:ParallelGCThreads来设置执行内存回收的线程数。

3. Parallel Scavenge收集器(新生代,复制,多线程,吞吐量优先)

吞吐量=运行用户代码时间/(运行用户代码时间+gc时间)

Parallel Scavenge收集器不像别的收集器关注的是减少用户停顿时间,而是吞吐量。

-XX:+UseParallelGC开关控制

-XX:MaxGCPauseMillis控制最大gc停顿时间

-XX:GCTimeRatio设置吞吐量

-XX:+UseAdaptiveSizePolicy,自动的动态调整停顿时间和吞吐量,GC的自适应调节策略,适合新手。

4. Serial Old收集器(老年代,标记-整理,单线程)

是Serial收集器的老年代版本

1.5之前和Parallel Scavenge搭配使用或者作为cms的备案

5. Parallel Old收集器(老年代, 标记-整理,多线程)

Parallel Scavenge的老年代版本,适合注重吞吐量和cpu资源敏感的场合

6. CMS收集器(老年代,标记-清理,多线程)

以获取最短回收停顿时间为目标的收集器。

过程:

  1. 初始标记:标记一下GCROOT能关联到的对象stw
  2. 并发标记:GCROOT Tracing
  3. 重新标记:修正并发标记期间产生变动的引用stw
  4. 并发清除

    在初始标记和重新标记的时候需要stop the world

优点:

并行,停顿小。

缺点:

  1. cpu资源敏感:
    因为并发导致吞吐量降低,随着cpu数量的下降对程序影响越大
  2. 无法处理浮动垃圾:
    因为并发清理是并发执行的,所以此时还是会产生心的垃圾,称之为浮动垃圾。这部分垃圾只能等待下一次的GC。

    这里产生一个问题就是老年代不能等到完全的利用,需要一部分用来存储浮动垃圾,如果预留的内存无法满足需要就会产生“ConcurrentModeFailure”,这是会启用Serial Old收集器进行gc。

  3. 使用标记-清除算法产生大量内存碎片

7. G1收集器

特点:

  1. 缩短stop the world停顿时间,通过并发的方式将停顿并发执行从而取消停顿。
  2. 分代收集,不需要其他收集器单独管理整个gc堆,采用不同方式处理新对象和老对象
  3. 不会产生碎片。
  4. 可预测的停顿

G1收集的范围是整个新生代和老年代,但是他是将整个堆划分为多个大小相等的独立区域(Region),它会优先回收价值最大的Region,保证在有限时间获取最高的收集效率。根据用户所期望的GC停顿时间来指定回收计划。

  1. 初始标记

标记GC ROOTs能直接关联到的对象,耗时短

  1. 并发标记

可达性分析,耗时长,但是可以和用户线程并发

  1. 最终标记

需要停顿,但是可以并行执行

  1. 筛选回收

对回收价值和成本进行排序

其他收集器的范围都是整个新生代或者老年代,G1自己管理整个,他将heap的内存布局划分成多个大小相等的独立区域(region),虽然还保留新生代和老年代的概念。

垃圾堆积的价值:回收所获得的空间大小以及回收所需时间的经验值的关系
维护一个优先队列,优先回收价值最大的region

g1和cms区别

CMS收集器:是基于标记清除算法实现的,一般就是初始标记,并发标记,重新标记,并发清除,目的是实现最短的响应回收时间。保证系统的响应时间,减少垃圾收集时的停顿时间
G1收集器:他的过程是初始标记、并发标记、最终标记、筛选回收,基于标记整理算法实现,以吞吐量优先,保证保证吞吐量的。

内存分配机制

新生代与老年代

为了优化垃圾回收的性能,将堆分为了新生代和老年代

优点:

  1. 是简化了新生对象的分配(只在新生代分配内存),
  2. 是可以针对不同区域使用更有效的垃圾回收算法。
  3. 新生代

    通过广泛研究面向对象实现的应用,发现一个共同特点:很多对象的生存时间都很短。同时研究发现,新生对象很少引用生存时间长的对象。结合这2个特点,很明显 GC 会频繁访问新生对象。在新生代中,GC可以快速标记回收”死对象”,而不需要扫描整个Heap中的存活一段时间的”老对象”。

SUN/Oracle 的HotSpot JVM 又把新生代进一步划分为3个区域:一个相对大点的区域,称为”伊甸园区(Eden)”;两个相对小点的区域称为”From 幸存区(survivor)”和”To 幸存区(survivor)”。按照规定,新对象会首先分配在 Eden 中(如果新对象过大,会直接分配在老年代中)。在GC中,Eden 中的对象会被移动到survivor中,直至对象满足一定的年纪(定义为熬过GC的次数),会被移动到老年代。

  • 新生代GC(Minor GC):发生在新生代的垃圾收集动作,频繁,回收速度快
  • 老年代GC(Full GC/ Major GC):发生在老年代的GC,一般比Minor GC慢10倍

    对象优先在Eden分配

    当Eden不够时,发起一次Minor GC

    大对象直接进入老年代

    比如很长的数组和字符串

—XX:PretenureSizeThreshold参数,零大于这个值的对象直接在老年代分配

长期存活的对象进入老年代

经过一次Minor GC后存活进入Survivor的对象设置为1岁,每经过1次Minor GC加一岁,达到一定岁数进入老年代。

动态对象年龄判定

survivor空间中相同年龄所有对象大小的总和大于单个survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代

空间分配担保

在Minor Gc前检查老年代的连续空间大于新生代对象总大小,if (true)则这次Minor GC是安全的。

只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会尽心Minor GC 否则 full GC。

参考地址

http://ifeve.com/useful-jvm-flags-part-5-young-generation-garbage-collection/

java内存区域

发表于 2018-03-04 | 分类于 jvm | 阅读次数:

一、运行时数据区域

1. 程序计数器(线程私有)

可以看做当前线程所执行的字节码的行号指示器,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,他们之间互不影响独立存储,若线程执行的是一个java方法,则记录的时候是正在执行的虚拟机字节码指令的地址;若执行的是native方法,则为空。

2. Java虚拟机栈(线程私有)

java方法执行的内存模型,方法在执行的时候都会创建一个栈帧(stack frame),方法从调用直至执行完成的过程,就对应一个栈帧在虚拟机栈中入栈到出栈的过程。马士兵说的栈就是这里的虚拟机栈,或者说是虚拟机栈中的局部变量表部分。

局部变量表存放编译期可知的各种基本数据类型、对象引用和returnAddress类型。

局部变量表的内存在编译器完成分配,运行期间不会改变局部变量表的大小。

3. 本地方法栈

作用同上,针对native房阿发

4. java堆(线程共享)

存放对象实例,所有的对象实例和数组都要在堆上分配。不需要连续的内存,可以选择固定大小或者可扩展

5. 方法区non-heap(线程共享,永生代)

方法区是java堆的逻辑部分(可能因为都是线程共享的?),存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。jvm规范将他归为堆的逻辑部分,但是他的别名叫Non-Heap。不需要连续的内存,可以选择固定大小或者可扩展

永生代是java虚拟机在HotSpot上的实现,在去永生代之后,方法区作为逻辑概念仍然存在,只不过是通过元空间的形式实现。

6. 运行时常量池

是方法区的一部分,Class文件中也有常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

字面量:如文本字符串、声明为final的常量等

符号引用:

  1. 类和接口的全限定名
  2. 字段的名称和描述符
  3. 方法的名称和描述符

运行时常量池还具有动态性,并非预置在Class文件中的常量池才能从编译器产生进入运行时常量池,运行期间也可以,String类的intern()。

7. 直接内存

nio通过native函数库直接分配堆外内存,再通过一个存储在java堆中的directbytebuffer对象作为这块内存的引用进行操作,避免在java堆和native堆中来回复制数据从而提高性能。

二、对象的深入了解

1. 对象创建过程

当遇到new指令之后

  1. 类检查:检查是否是已解释的类(查符号引用),是否已经初始化(否则先执行类加载)
  2. 分配内存:根据使用的GC机制使用不同的分配方法
    1. 堆中内存规整,使用“指针碰撞”(一边是空闲的,一边是使用的,通过指针移动划分)
    2. 内存不规整,使用“空闲列表”(维护一个查询列表)
  3. 解决临界资源(内存)的问题
    1. 进行同步处理,CAS(乐观锁)+重试。CAS:通过3个值,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
    2. 本地线程分配缓冲TLAB:每个线程在java堆中预先分配一小块内存。
  4. 内存分配完后将内存初始化为0,若使用TLAB则在TLAB分配时进行,这样保证对象的实例字段在为初始值的时候就可以直接使用。
  5. 设置对象头 虚拟机的实例化完成

new指令后接着执行方法按照程序员意愿进行初始化。

2. 对象的内存布局

对象分为对象头、实例数据、对齐填充

对象头:

  1. 存储对象自身的运行时数据(哈希码,gc分代年龄,锁状态标志,线程持有的锁、偏向线程ID、偏向时间戳,会根据状态服用自己的存储空间。
  2. 类型指针,指向类元数据的指针,确定这个对象是哪个类的实例,但是非必须。若为数组类型,还要记录数组长度。

实例数据:程序代码中所定义的各种类型的字段。

对齐补充: 因为对象大小是8字节的整数倍

3. 对象的访问定位

java程序通过栈上的reference数据来操作堆上的具体对象,有2种主流实现方法

  1. 句柄


会在堆中划分一个句柄池,reference中存储的就是句柄的地址,句柄包含了指向实例数据的指针和类型数据的指针(类描述信息)

优点:是存储了稳定的句柄地址,GC后对象移动后只要修改句柄中的实例数据指针就好,reference不用修改

  1. 直接指针(HotSpot默认)

reference存储的直接就是对象地址,对象中包括实例数据和存储类型数据的指针,优点是减少了一次指针定位的开销,加快了速度

4. 内存异常实战

首先明确两个概念,内存溢出和内存泄露。

  1. 内存溢出 out of memory:是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;比如申请了一个integer,但给它存了long才能存下的数,那就是内存溢出。

  2. 内存泄漏:指你向系统申请分配内存进行使用(new),可是使用完了以后却不归还(delete),结果你申请到的那块内存你自己也不能再访问(也许你把它的地址给弄丢了),而系统也不能再次将它分配给需要的程序。

比如各种连接没有close掉,在之前的版本中出现大量的字符串没有gc掉

memory leak会最终会导致out of memory。

1. 堆溢出

通过jvm args:

-XX:+HeapDumpOnOutOfMemoryError :可以让虚拟机在出现内存溢出异常的时候dump当前的内存堆转储快照。通过Eclipse Memory Analyzer对文件进行分析。

-Xms20m:设置堆的最小内存值为20mb
-Xmx20m:设置堆的最大内存值为20mb

2. 栈溢出

-Xss设置栈内存

由于os分配给进程内存是有限制的,比如32位的windows是2gb,而jvm提供参数控制堆和方法区的最大值,所以剩余内存为2gb-Xmx-最大方法区容量,程序计数器可以忽略不计,所以若jvm的也忽略不计,那剩下的内存就由本地方法栈和虚拟机栈瓜分。
如果建立过多线程导致内存溢出,在不能减少线程数或更换高位虚拟机的情况下只能通过减少最大堆和栈容量来换取更多的线程。

3.1 方法区和运行时常量池溢出

String.intern()是一个Native方法,如果字符串常量池中包含等于此String对象的字符串,则返回代表池中这个字符串的String对象,否则将String对象包含的字符串添加到常量池中并且返回这个字符串的String对象。这里推荐一篇介绍JDK6和JDK7中String.inter()区别的帖子https://tech.meituan.com/in_depth_understanding_string_intern.html/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M
* @author zzm
*/
public class RuntimeConstantPoolOOM {

public static void main(String[] args) {
// 使用List保持着常量池引用,避免Full GC回收常量池行为
List<String> list = new ArrayList<String>();
// 10MB的PermSize在integer范围内足够产生OOM了
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}

这段代码在1.6中会因为常量池溢出,而在1.7中会继续执行,就是因为去永久代,具体细节稍后会详细讨论。

一个有意思的例子

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

public static void main(String[] args) {
public static void main(String[] args) {
String str1 = new StringBuilder("中国").append("钓鱼岛").toString();
System.out.println(str1.intern() == str1);

String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);
} }
}

在1.6中会出现2个false;在1.7中会得到一个true一个false;

这里有2个问题:

  1. 在1.6中intern()会把首次遇到的字符串复制到常量池,返回的是常量池中字符串的实例引用,但是由StringBuilder创建的字符串实例却会出现在堆上,所以是false; 问题出现了,
    有些方法会在堆中创建重复的对象而不直接引用常量池。 知乎高人这么理解的难道每创建一个新实例就去常量池里查找一下,这部分开销怎么办?
  2. 在1.7中该方法不会再复制实例,只是在常量池中记录首次出现的实例引用,所以,是同一个实例。但是!!,在jvm中,类似“java”、“int”这样的好像是字符串常量,就算用户不创建,他也会存在,可能是因为在初始化jvm的源码中用到的,所以使用的时候要注意。

3.2 java8新特性,去永久代

在JDK8之前的HotSpot虚拟机中,类的元数据和常量池存放在一个叫做永久代的区域,所谓永久代,只是jvm方法区这个概念在hotspot虚拟机中的一种实现而已,在别的虚拟机实现中,并没有永久代这个概念。方法区是java虚拟机规范去中定义的一种概念上的区域,具有什么功能。由于永久代的存在,也确实引发了一些内存泄露的问题,永久代中的元数据可能会随着每一次Full GC发生而进行移动。并且为永久代设置空间大小也是很难确定的,因为这其中有很多影响因素,比如类的总数,常量池的大小和方法数量等。
在java8中取消了永久代,方法区作为概念区域仍然存在,原先的永久代中的类的元信息会被放入本地内存(native memory)即元空间metaspace,类的静态变量和内部字符串会被放入堆中,这样可以加载多少类的元数据就不在由MaxPermSize控制, 而由系统的实际可用空间就是系统可用内存空间来控制。

4. 本机直接内存溢出

directmemory导致的内存溢出明显特征就是heap dump文件没有明显异常,若oom后的dump文件很小,程序直接或间接使用了NIO就可能是这个的原因

IOC

发表于 2018-03-03 | 分类于 spring | 阅读次数:

这几天一直在看IOC的相关内容,无奈IOC实在内容太多,自己感觉有点消化不良,尤其是在IOC初始化和依赖注入的源码中有一系列名字超长的类,读了两天感觉自己一脸懵逼,这里简单的写一下自己的总结吧。

一. 概述

1. 概念

IOC即控制反转,也有叫依赖注入的,对于这二者是否有区别,维基百科上说依赖注入是Martin Fowler这个大神给IOC提出来的新名字。所谓控制反转,也就是把合作对象的引用或依赖关系的控制权反转交给IOC容器。

2. 注入方式

最常见的注入方式有三种:

  • 构造器注入
  • setter注入
  • 自动装配
  • 接口注入

2.1 构造器注入

这种方式的注入是指带有参数的构造函数注入,看下面的例子,我创建了两个成员变量SpringDao和User,但是并未设置对象的set方法,所以就不能支持第一种注入方式,这里的注入方式是在SpringAction的构造函数中注入,也就是说在创建SpringAction对象时要将SpringDao和User两个参数值传进来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class SpringAction {  
//注入对象springDao
private SpringDao springDao;
private User user;

public SpringAction(SpringDao springDao,User user){
this.springDao = springDao;
this.user = user;
System.out.println("构造方法调用springDao和user");
}

public void save(){
user.setName("卡卡");
springDao.save(user);
}
}

在XML文件中同样不用的形式,而是使用标签,ref属性同样指向其它标签的name属性:

1
2
3
4
5
6
7
8
<!--配置bean,配置后该类由spring管理-->  
<bean name="springAction" class="com.bless.springdemo.action.SpringAction">
<!--(2)创建构造器注入,如果主类有带参的构造方法则需添加此配置-->
<constructor-arg ref="springDao"></constructor-arg>
<constructor-arg ref="user"></constructor-arg>
</bean>
<bean name="springDao" class="com.bless.springdemo.dao.impl.SpringDaoImpl"></bean>
<bean name="user" class="com.bless.springdemo.vo.User"></bean>

2.2 setter注入

这是最简单的注入方式,假设有一个SpringAction,类中需要实例化一个SpringDao对象,那么就可以定义一个private的SpringDao成员变量,然后创建SpringDao的set方法(这是ioc的注入入口):

1
2
3
4
5
6
7
8
9
10
11
12
13
public class SpringAction {  
//注入对象springDao
private SpringDao springDao;

//一定要写被注入对象的set方法
public void setSpringDao(SpringDao springDao) {
this.springDao = springDao;
}

public void ok(){
springDao.ok();
}
}

随后编写spring的xml文件,中的name属性是class属性的一个别名,class属性指类的全名,因为在SpringAction中有一个公共属性Springdao,所以要在标签中创建一个标签指定SpringDao。标签中的name就是SpringAction类中的SpringDao属性名,ref指下面,这样其实是spring将SpringDaoImpl对象实例化并且调用SpringAction的setSpringDao方法将SpringDao注入:

1
2
3
4
5
6
<!--配置bean,配置后该类由spring管理-->  
<bean name="springAction" class="com.bless.springdemo.action.SpringAction">
<!--(1)依赖注入,配置当前类中相应的属性-->
<property name="springDao" ref="springDao"></property>
</bean>
<bean name="springDao" class="com.bless.springdemo.dao.impl.SpringDaoImpl"></bean>

2.3 自动装配

  • 组建扫描(component scanning):Spring会自动发现应用上下文中锁创建的bean
  • 自动装载(autowiring):Spring自动满足bean之间的依赖
    这两个分别对应了注解@Component和@Autowiring

@Component:这个注解表明该类会作为组件类,并告知Spring要为这个类创建bean
@AutoWiring:可以用在构造方法或set方法上,表明注入一个依赖

2.4 接口注入

这种一般就是通过简单工厂或者工厂方法来实现注入,没用过就不写了。

二. 核心类

1. BeanFactory和ApplicationContext

在Spring的IOC容器中主要就是这两个分支,BeanFactory是最基础的IOC容器,是所有容器的父接口,他只实现了最基础功能,相当于屌丝版,而ApplicationContext是实现了BeanFactory的高富帅版,高级IoC容器,除了基本的IoC容器功能外,支持不同信息源、访问资源、支持事件发布等功能。

继承了以下接口:

  • ListableBeanFactory:继承自BeanFactory,在此基础上,添加了containsBeanDefinition、getBeanDefinitionCount、getBeanDefinitionNames等方法。
  • HierarchicalBeanFactory:继承自BeanFactory,在此基础之上,添加了getParentBeanFactory、containsLocalBean这两个方法。
  • AutoWireCapableBeanFactory:继承自BeanFactory
  • MessageSource:用于获取国际化信息
  • ApplicationEventPublisher:因为ApplicationContext实现了该接口,因此spring的ApplicationContext实例具有发布事件的功能。

2.Resource

在Spring内部,针对于资源文件有一个统一的接口Resource表示。其主要实现类有ClassPathResource、FileSystemResource、UrlResource、ByteArrayResource、ServletContextResource和InputStreamResource。Resource接口中主要定义有以下方法:

  • exists():用于判断对应的资源是否真的存在。
  • isReadable():用于判断对应资源的内容是否可读。需要注意的是当其结果为true的时候,其内容未必真的可读,但如果返回false,则其内容必定不可读。
  • isOpen():用于判断当前资源是否代表一个已打开的输入流,如果结果为true,则表示当前资源的输入流不可多次读取,而且在读取以后需要对它进行关闭,以防止内存泄露。该方法主要针对于InputStreamResource,实现类中只有它的返回结果为true,其他都为false。
  • getURL():返回当前资源对应的URL。如果当前资源不能解析为一个URL则会抛出异常。如ByteArrayResource就不能解析为一个URL。
  • getFile():返回当前资源对应的File。如果当前资源不能以绝对路径解析为一个File则会抛出异常。如ByteArrayResource就不能解析为一个File。
  • getInputStream():获取当前资源代表的输入流。除了InputStreamResource以外,其它Resource实现类每次调用getInputStream()方法都将返回一个全新的InputStream。

实现类:

  • ClassPathResource可用来获取类路径下的资源文件。假设我们有一个资源文件test.txt在类路径下,我们就可以通过给定对应资源文件在类路径下的路径path来获取它,new ClassPathResource(“test.txt”)。
  • FileSystemResource可用来获取文件系统里面的资源。我们可以通过对应资源文件的文件路径来构建一个FileSystemResource。FileSystemResource还可以往对应的资源文件里面写内容,当然前提是当前资源文件是可写的,这可以通过其isWritable()方法来判断。FileSystemResource对外开放了对应资源文件的输出流,可以通过getOutputStream()方法获取到。
  • UrlResource可用来代表URL对应的资源,它对URL做了一个简单的封装。通过给定一个URL地址,我们就能构建一个UrlResource。
  • ByteArrayResource是针对于字节数组封装的资源,它的构建需要一个字节数组。
  • ServletContextResource是针对于ServletContext封装的资源,用于访问ServletContext环境下的资源。ServletContextResource持有一个ServletContext的引用,其底层是通过ServletContext的getResource()方法和getResourceAsStream()方法来获取资源的。
  • InputStreamResource是针对于输入流封装的资源,它的构建需要一个输入流。

3.ResourceLoader

通过上面介绍的Resource接口的实现类,我们就可以使用它们各自的构造函数创建符合需求的Resource实例。但是在Spring中提供了ResourceLoader接口,用于实现不同的Resource加载策略,即将不同Resource实例的创建交给ResourceLoader来加载,这也是ApplicationContext等高级容器中使用的策略。

接口中有两个主要的方法:

  • getResource():在ResourceLoader接口中,主要定义了一个方法:getResource(),它通过提供的资源location参数获取Resource实例,该实例可以是ClasPathResource、FileSystemResource、UrlResource等,但是该方法返回的Resource实例并不保证该Resource一定是存在的,需要调用exists方法判断。
  • getResourceByPath:这个方法被声明为protected,所以在它的子类中基本都重写了这个方法。

4. BeanDefinition

一个BeanDefinition描述了一个bean的实例,包括属性值,构造方法参数值和继承自它的类的更多信息。这个东西会贯穿整个IOC的初始化

三. Bean的注入

1. IOC初始化

上面说到了BeanDefinition会伴随整个IOC初始化的过程初始化,其实整个IOC容器的初始化过程大致分为以下四个步骤:

  1. Resource定位过程
  2. BeanDefinition载入
  3. BeanDefinition解析
  4. BeanDefinition注册

最终配置的bean以BeanDefinition的数据与结构存在于IOC容器之中,这个过程不涉及bean的依赖注入,也不产生任何bean。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class FileSystemXmlApplicationContext extends AbstractXmlApplicationContext {
//核心构造器
public FileSystemXmlApplicationContext(String[] configLocations, boolean refresh, ApplicationContext parent)
throws BeansException {
super(parent);
setConfigLocations(configLocations);
if (refresh) {
refresh();//Ioc容器的refresh()过程,是个非常复杂的过程,但不同的容器实现这里都是相似的,因此基类中就将他们封装好了
}
}
//通过构造一个FileSystemResource对象来得到一个在文件系统中定位的BeanDefinition
//采用模板方法设计模式,具体的实现用子类来完成
@Override
protected Resource getResourceByPath(String path) {
if (path != null && path.startsWith("/")) {
path = path.substring(1);
}
return new FileSystemResource(path);
}
}

这里我们主要看一下这部分

1
2
3
4
5
6
7
8
public FileSystemXmlApplicationContext(String[] configLocations, boolean refresh, ApplicationContext parent)
throws BeansException {
super(parent);
setConfigLocations(configLocations);
if (refresh) {
refresh();//Ioc容器的refresh()过程,是个非常复杂的过程,但不同的容器实现这里都是相似的,因此基类中就将他们封装好了
}
}

这里我们可以看到调用了一个 refresh(),这个方法在父类AbstractApplicationContext中已经封装好了。它详细描述了整个ApplicationContext的初始化过程,比如BeanFactory的更新、MessageSource和PostProcessor的注册等。这里看起来像是对ApplicationContext进行初始化的模版或执行提纲,这个执行过程为Bean的生命周期管理提供了条件。

refresh为初始化IoC容器的入口,但是具体的资源定位还是在XmlBeanDefinitionReader读入BeanDefinition时完成,loadBeanDefinitions() 加载BeanDefinition的载入。由于源码分析过于冗长我就直接介绍一下每一步的大致思路,如果想看具体的分析可以参考http://www.cnblogs.com/ITtangtang/p/3978349.html,这篇文章比较详细。这里直接给出一个流程性的总结:

  1. 首先需要获得一个IOC容器才能操作其控制的Bean,对于IOC容器的初始化来说,他通过一个refresh()函数作为开头,首先判断是否已经创建了BeanFactory,如果创建了则销毁关闭该BeanFactory,接着会创建相应的读取器(Reader),通过相应loadBeanDefinitions函数获取资源定位
  2. 对xml的资源文件的加载将他们转换为一个Document对象进行处理,载入过程实际上就是Resource对象转换成Document对象的过程,也就是一个XML文件解析的过程,Spring中使用的是JAXP解析,生成的Document文件就是org.w3c.dom.Document中的Document文件;
  3. 就是解析相应的Document对象,这个没有什么好说的类似于用DOM解析xml文件
  4. 向IoC容器注册解析的BeanDefiniton ,完成前面的步骤用户定义的BeanDefiniton已经在IoC容器里建立相应的数据结构和表示,但不能直接使用,需要进行注册,简单来说就是通过一个ConcurrentHashMap存储。

2. 依赖注入

假设我们已经完成了IoC容器的初始化,首先要注意到的一点,Spring中的依赖注入是lazy-loading,即用户第一次向容器索要Bean,调用相应的getBean()函数,但是也能通过设置Bean的lazy-init属性来控制预实例化过程,这个预实例化在初始化容器时完成Bean的依赖注入。

他的主要流程也就下面这几个步骤:

  1. AbstractBeanFactory中的getBean方法来获取Bean:

在缓存中查找,如果存在则直接返回,否则开始下一步创建Bean

  1. AbstractAutowireCapableBeanFactory类中创建Bean,这个类中有几个关键方法:

    1. createBean方法:创建容器指定的Bean实例对象的入口

同时还对创建的Bean实例对象进行初始化处理比如init-method、后置处理器等,然后调用下面的两个方法创建实例并注入依赖

2. createBeanInstance方法:创建Bean的Java实例对象,分两种情况:

对于使用工厂方法和自动装配特性的bean的实例化:则调用对应的工厂方法或者参数匹配的构造方法即可完成实例化对象的工作
否则调用默认的无参构造器进行实例化即SimpleInstantiationStrategy的instantiate方法:

1. 使用Java的反射技术
2. 使用CGLIB
  1. populateBean方法:实例化之后,根据属性类型决定是否需要解析,最后通过BeanWrapperImpl类完成对属性的注入,对属性的类型也要进行判断:
  • 属性值类型不需要转换时,不需要解析属性值,直接准备进行依赖注入
  • 属性值需要进行类型转换时,如对其他对象的引用等,首先需要解析属性值,然后对解析后的属性值进行依赖注入。解析过程由BeanDefinitionValueResolver类的setPropertyValue方法完成
  1. BeanWrapperImpl对Bean属性的依赖注入:
  • 对于集合类属性,将其属性值解析为目标类型的集合后直接赋值给属性。
  • 对于非集合类型的属性,大量使用了JDK的反射和内省机制,通过属性的getter方法(reader method)获取指定属性注入以前的值,同时调用属性的setter方法(writer method)为属性设置注入后的值。

总结一下就是先判断是否有缓存,有的话直接取,没有就创建实例,接着会判断bean是否能被实例化,以及实例化这个bean的方法,根据相应策略来创建bean,之后填充属性完成创建并返回。

3. 循环依赖

循环依赖就是循环引用,就是两个或多个Bean相互之间的持有对方,比如CircleA引用CircleB,CircleB引用CircleC,CircleC引用CircleA,则它们最终反映为一个环。此处不是循环调用,循环调用是方法之间的环调用。

循环调用是无法解决的,除非有终结条件,否则就是死循环,最终导致内存溢出错误。Spring容器循环依赖包括构造器循环依赖和setter循环依赖,那Spring容器如何解决循环依赖呢?首先让我们来定义循环引用类:

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
44
45
46
47
48
package cn.javass.spring.chapter3.bean;  
public class CircleA {
private CircleB circleB;
public CircleA() {
}
public CircleA(CircleB circleB) {
this.circleB = circleB;
}
public void setCircleB(CircleB circleB)
{
this.circleB = circleB;
}
public void a() {
circleB.b();
}
}
package cn.javass.spring.chapter3.bean;
public class CircleB {
private CircleC circleC;
public CircleB() {
}
public CircleB(CircleC circleC) {
this.circleC = circleC;
}
public void setCircleC(CircleC circleC)
{
this.circleC = circleC;
}
public void b() {
circleC.c();
}
}
package cn.javass.spring.chapter3.bean;
public class CircleC {
private CircleA circleA;
public CircleC() {
}
public CircleC(CircleA circleA) {
this.circleA = circleA;
}
public void setCircleA(CircleA circleA)
{
this.circleA = circleA;
}
public void c() {
circleA.a();
}
}

1.构造器循环依赖

表示通过构造器注入构成的循环依赖,此依赖是无法解决的,只能抛出BeanCurrentlyInCreationException异常表示循环依赖。

如在创建CircleA类时,构造器需要CircleB类,那将去创建CircleB,在创建CircleB类时又发现需要CircleC类,则又去创建CircleC,最终在创建CircleC时发现又需要CircleA;从而形成一个环,没办法创建。
Spring容器将每一个正在创建的Bean 标识符放在一个“当前创建Bean池”中,Bean标识符在创建过程中将一直保持在这个池中,因此如果在创建Bean过程中发现自己已经在“当前创建Bean池”里时将抛出BeanCurrentlyInCreationException异常表示循环依赖;而对于创建完毕的Bean将从“当前创建Bean池”中清除掉。
这个调用会是这样的流程:

  1. Spring容器创建“circleA” Bean,首先去“当前创建Bean池”查找是否当前Bean正在创建,如果没发现,则继续准备其需要的构造器参数“circleB”,并将“circleA” 标识符放到“当前创建Bean池”;
  2. Spring容器创建“circleB” Bean,首先去“当前创建Bean池”查找是否当前Bean正在创建,如果没发现,则继续准备其需要的构造器参数“circleC”,并将“circleB” 标识符放到“当前创建Bean池”;S
  3. pring容器创建“circleC” Bean,首先去“当前创建Bean池”查找是否当前Bean正在创建,如果没发现,则继续准备其需要的构造器参数“circleA”,并将“circleC” 标识符放到“当前创建Bean池”;
  4. 到此为止Spring容器要去创建“circleA”Bean,发现该Bean 标识符在“当前创建Bean池”中,因为表示循环依赖,抛出BeanCurrentlyInCreationException。

1.setter循环依赖

对于setter注入造成的依赖是通过Spring容器提前暴露刚完成构造器注入但未完成其他步骤(如setter注入)的Bean来完成的,而且只能解决单例作用域的Bean循环依赖。具体步骤如下:

  1. Spring容器创建单例“circleA” Bean,首先根据无参构造器创建Bean,并暴露一个“ObjectFactory ”用于返回一个提前暴露一个创建中的Bean,并将“circleA” 标识符放到“当前创建Bean池”;然后进行setter注入“circleB”;
  2. Spring容器创建单例“circleB” Bean,首先根据无参构造器创建Bean,并暴露一个“ObjectFactory”用于返回一个提前暴露一个创建中的Bean,并将“circleB” 标识符放到“当前创建Bean池”,然后进行setter注入“circleC”;
  3. Spring容器创建单例“circleC” Bean,首先根据无参构造器创建Bean,并暴露一个“ObjectFactory ”用于返回一个提前暴露一个创建中的Bean,并将“circleC” 标识符放到“当前创建Bean池”,然后进行setter注入“circleA”;进行注入“circleA”时由于提前暴露了“ObjectFactory”工厂从而使用它返回提前暴露一个创建中的Bean;
  4. 最后在依赖注入“circleB”和“circleA”,完成setter注入。

对于“prototype”作用域Bean,Spring容器无法完成依赖注入,因为“prototype”作用域的Bean,Spring容器不进行缓存,因此无法提前暴露一个创建中的Bean。

四. 参考

http://paine1690.github.io/2017/01/02/spring/Spring%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90(2)%E4%BE%9D%E8%B5%96%E6%B3%A8%E5%85%A5%E7%9A%84%E5%AE%9E%E7%8E%B0/
http://blog.battcn.com/2018/01/17/spring/spring-4/#more
http://www.cnblogs.com/ITtangtang/p/3978349.html
http://jinnianshilongnian.iteye.com/blog/1415278

AOP

发表于 2018-02-28 | 分类于 spring | 阅读次数:

引入

AOP(Aspect Oriented Programming),即面向切面编程,可以说是OOP(Object Oriented Programming,面向对象编程)的补充和完善。OOP引入封装、继承、多态等概念来建立一种对象层次结构,用于模拟公共行为的一个集合。不过OOP允许开发者定义纵向的关系,但并不适合定义横向的关系,例如日志功能。日志代码往往横向地散布在所有对象层次中,而与它对应的对象的核心功能毫无关系对于其他类型的代码,如安全性、异常处理和透明的持续性也都是如此,这种散布在各处的无关的代码被称为横切(cross cutting),在OOP设计中,它导致了大量代码的重复,而不利于各个模块的重用。

AOP技术恰恰相反,它利用一种称为”横切”的技术,剖解开封装的对象内部,并将那些影响了多个类的公共行为封装到一个可重用模块,并将其命名为”Aspect”,即切面。所谓”切面”,简单说就是那些与业务无关,却为业务模块所共同调用的逻辑或责任封装起来,便于减少系统的重复代码,降低模块之间的耦合度,并有利于未来的可操作性和可维护性。

使用”横切”技术,AOP把软件系统分为两个部分:核心关注点和横切关注点。业务处理的主要流程是核心关注点,与之关系不大的部分是横切关注点。横切关注点的一个特点是,他们经常发生在核心关注点的多处,而各处基本相似,比如权限认证、日志、事物。AOP的作用在于分离系统中的各种关注点,将核心关注点和横切关注点分离开来。

一.术语

1.横切关注点

软件开发中,散布于应用中多处的功能被称为横切关注点,如事务、日志、安全。

2.advice(通知) what when

切面的工作被称为通知,他定义了切面的工作是什么和何时工作

5种类型通知:

  1. 前置通知before
  2. 后置通知after(不关心方法的输出是什么)
  3. 返回通知after-returning(方法成功执行)
  4. 异常通知after-throwing(方法返回异常)
  5. 环绕通知around(包裹被通知方法,之前和之后都自定义行为)

3.join point连接点

应用执行过程中,能够插入切面的所有“点”(时机),使用通知的时机,即触发通知的事件方法

4.poincut 切点 where

如果说通知定义切面的“什么”和“何时”,那么切点就定义了“何处”。切点的定义会匹配通知所要织入的一个或多个连接点。我们通常会使用明确的类和方法名称来指定这些切点,或是利用正则表达式定义匹配的类和方法名称模式来指定这些切点。

5.切面aspect = advice + poincut

切面是通知和切点的结合,通知和切点共同定义了关于切面的全部内容—-它是什么,在何时和何处完成其功能

通过 代理 来实现切面,代理类包裹了目标bean,先拦截对被通知方法的调用,再转发给真正的bean,在调用bean之前,先执行切面逻辑。直到需要被代理的bean时,才创建代理对象。正因为spring会在运行时才创建代理对象,所以不需要特殊的编译器织入springAOP切面。

定义切面

@AspectJ :表明该类不仅是一个pojo还是以个切面

@Pointcut 在一个@Aspect切面内定义可重用的切点

@Pointcut(“execution( concert.Performance.perform(..))”)
public void performance() {}
将切点依附于performance()方法,可以在定义切点表达式的时候用performance()代替execution(
concert.Performance.perform(..))

  • 环绕通知
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Aspect
    public class Audience {
    @Pointcut("excution(** concert.Performance.perform(..))")
    public void performance() {}

    @Around("performance()")
    public void watchPerformance(ProceedingJoinPoint jp) {
    try {
    System.out.println("Silencing cell phones");
    System.out.pringln("Taking seats");
    jp.proceed():
    Sytem.out.println("CLAP CLAP CLAP!!!");
    } catch (THrowable e) {
    SYstem.out.println("Demanding a refund");
    }
    }
    }

如果不调用proceed()方法,那么会阻塞被通知方法的调用。
可以不调用proceed()方法,也可以多次调用proceed()方法,这样的场景就是实现重试逻辑,也就是在被通知方法失败后,进行重复尝试。

  • 切点拦截参数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Pointcut("excution(** concert.Performance.perform(..)) && args(trackNumber")
    public void trackPlayed(int trackNumber) {}

    @Before("trackPlayed(trackNumber)")
    public void countTrack(int trackNumber) {
    int currentCount = getPlayCount(trackNumber);
    trackCounts.put(trackNumber, currentCount + 1);
    }
    public int getPlayCount(int trackNumber) {
    return trackCounts.containsKey(trackNumber) ? trackCounts.get(trackNumber) : 0;
    }

切点定义中的参数与切点方法中的参数名一样就可以实现从命名切点到通知方法的参数转移。

6.Introduction引入

向现有的类添加新方法和属性。

给bean添加一个新的功能,需要一个引入代理去实现相关的功能。

1
2
3
4
5
@Aspect 
public class EncoreableIntroducer {
@DeclareParents(value="concert.Performance+", defualtImpl=DefaultEncoreable.class)
public static Encoreable ecoreable;
}

  • value指定了哪种类型的bean要引入该接口。在这里是所有实现Performance的类型。加号表示Performance的所有子类而不是本身
  • defaultImplement指定了为引入功能提供实现的类 实现类
  • @DeclareParents注解所标注的静态属性指明了要 引入的接口。

7.织入

把切面应用到目标对象并创建新的代理对象的过程。在目标生命周期有多个点可以进行织入:

  • 编译期
  • 类加载期
  • 运行期

二.spring对AOP的支持

Spring默认采取的动态代理机制实现AOP,当动态代理不可用时(代理类无接口)会使用CGlib机制。但Spring的AOP有一定的缺点,第一个只能对方法进行切入,不能对接口,字段,静态代码块进行切入(切入接口的某个方法,则该接口下所有实现类的该方法将被切入)。第二个同类中的互相调用方法将不会使用代理类。因为要使用代理类必须从Spring容器中获取Bean。第三个性能不是最好的

  • 基于代理的经典SpringAOP
  • 纯POJO切面
  • @AspectJ注解驱动的切面
  • 注入式AspectJ切面

由于Spring AOP构建在动态代理基础之上,所以其对AOP的支持局限于方法拦截。他不支持字段连接点和构造器连接点。

直到应用需要被代理的bean时,spring才会创建代理对象。如果使用的是ApplicationContext的话,在ApplicationContext从BeanFactory中加载所有bean的时候,Spring才会创建被dialing的对象。

动态代理

不修改原来的对象就能增加一系列新的功能

  1. jdk自带动态代理

Java在JDK1.3后引入的动态代理机制,使我们可以在运行期,目标类加载后动态的创建代理类

  1. CGlib

    使用动态字节码生成技术实现AOP原理是在运行期间目标字节码加载后,生成目标类的子类,将切面逻辑加入到子类中,所以使用Cglib实现AOP不需要基于接口。

JDK中动态代理实现流程

在java的动态代理机制中,有两个重要的类和接口,一个是 InvocationHandler(Interface)、另一个则是 Proxy(Class),这一个类和接口是实现我们动态代理所必须用到的。

使用方法:

  1. 创建调用处理器(InvocationHandler),在其中传入被代理对象
  2. 在调用处理器的invoke方法中加入代理逻辑,并调用被代理对象的被代理方法
  3. 通过Proxy.newProxyInstance方法获得代理对象
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
class DynamicProxyHandler implements InvocationHandler {
private Object proxied;
public DynamicProxyHandler(Object proxied) {
this.proxied = proxied;
}
public Object
invoke(Object proxy, Method method, Object[] args)
throws Throwable {
System.out.println("**** proxy: " + proxy.getClass() +
", method: " + method + ", args: " + args);
if(args != null)
for(Object arg : args)
System.out.println(" " + arg);
return method.invoke(proxied, args);
}
}
class SimpleDynamicProxy {
public static void consumer(Interface iface) {
iface.doSomething();
iface.somethingElse("bonobo");
}

public static void main(String[] args) {
RealObject real = new RealObject();

Interface proxy = (Interface)Proxy.newProxyInstance(
Interface.class.getClassLoader(),
new Class[]{ Interface.class },
new DynamicProxyHandler(real));
consumer(proxy);
}

Proxy.newProxyInstance方法有三个参数,分别是类加载器,一个希望该代理实现的接口列表以及传入他的被代理对象的调用处理器。

通过 Proxy.newProxyInstance创建的代理对象是在jvm运行时动态生成的一个对象,它并不是我们的InvocationHandler类型,也不是我们定义的那组接口的类型,而是在运行是动态生成的一个对象,并且命名方式都是这样的形式,以$开头,proxy为中,最后一个数字表示对象的标号。

总体逻辑大概是:通过InvocationHandler来包装被代理的方法,再根据(InvocationHandler)和需要代理的接口生成相应的代理对象,通过将相应的调用转发到代理对象,从而实现功能的包装。

由于在Proxy.newProxyInstance中,所有的方法都是通过h.invoke()实现的,这样就让所有的接口方法都被包裹上代理逻辑,也就是说当执行被代理操作的时候在代理对象内部,实际上是使用invoke()方法将请求转发给了调用处理器进行操作。

静态代理

既然说了动态代理,那我们就顺便提一下静态代理,虽然他没有动态代理那么好。

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

package test;

public interface Subject
{
public void doSomething();
}
package test;

public class RealSubject implements Subject
{
public void doSomething()
{
System.out.println( "call doSomething()" );
}
}
package test;

public class SubjectProxy implements Subject
{
Subject subimpl = new RealSubject();
public void doSomething()
{
//可以在这里加入代理逻辑
subimpl.doSomething();
//可以在这里加入代理逻辑
}
}
package test;

public class TestProxy
{
public static void main(String args[])
{
Subject sub = new SubjectProxy();
sub.doSomething();
}
}

刚开始我会觉得SubjectProxy定义出来纯属多余,直接实例化实现类完成操作不就结了吗?后来随着业务庞大,你就会知道,实现proxy类对真实类的封装对于粒度的控制有着重要的意义。但是静态代理这个模式本身有个大问题,如果类方法数量越来越多的时候,代理类的代码量是十分庞大的。所以引入动态代理来解决此类问题。

动态代理相对于静态的好处:

  1. Proxy类的代码量被固定下来,不会因为业务的逐渐庞大而庞大;
  2. 可以实现AOP编程,实际上静态代理也可以实现,总的来说,AOP可以算作是代理模式的一个典型应用;
  3. 解耦,通过参数就可以判断真实类,不需要事先实例化,更加灵活多变。
    这也是为什么Spring这么受欢迎的一个原因,
    Spring容器代替工厂,Spring AOP代替JDK动态代理,让面向切面编程更容易实现。
    在Spring的帮助下轻松添加,移除动态代理,且对源代码无任何影响。

HashMap源码解析

发表于 2018-02-24 | 分类于 java | 阅读次数:

〇.简介

Java为数据结构中的映射定义了一个接口java.util.Map,此接口主要有四个常用的实现类,分别是HashMap、Hashtable、LinkedHashMap和TreeMap,类继承关系如下图所示:

下面针对各个实现类的特点做一些说明:

  1. HashMap:它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。

  2. Hashtable:Hashtable是遗留类,很多映射的常用功能与HashMap类似,不同的是它承自Dictionary类,并且是线程安全的,任一时间只有一个线程能写Hashtable,并发性不如ConcurrentHashMap,因为ConcurrentHashMap引入了分段锁。Hashtable不建议在新代码中使用,不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。

  3. LinkedHashMap:LinkedHashMap是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。

  4. TreeMap:TreeMap实现SortedMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator遍历TreeMap时,得到的记录是排过序的。如果使用排序的映射,建议使用TreeMap。在使用TreeMap时,key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常。

对于上述四种Map类型的类,要求映射中的key是不可变对象。不可变对象是该对象在创建后它的哈希值不会被改变。如果对象的哈希值发生变化,Map对象很可能就定位不到映射的位置了。

在JDK8中HashMap的实现由原先的数组加链表,也就是通过链地址法,解决哈希冲突,变成了数组加链表加红黑树,如图所示

当链表长度大于8时,链表会变成红黑树,这样做的目的是为了改进之前由于hash函数选择不好导致链表过长的查询瓶颈,之后会具体介绍。

一. 关键参数及构造方法

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
/* ---------------- Fields -------------- */  

//存储节点的table数组,第一次使用的时候初始化,必要时resize,长度总是2的幂
transient Node<K,V>[] table;

//缓存entrySet,用于keySet() and values()
transient Set<Map.Entry<K,V>> entrySet;

//容器中元素的个数
transient int size;

//每次扩容和更改map结构的计数器
transient int modCount;

//阈值,当实际大小超过阈值时,会进行扩容
int threshold;

//装载因子
final float loadFactor;

//默认的初始容量,必须是2的幂次,出于优化考虑,默认16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

//默认的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;

//默认的装载因子,在无参构造器中默认设为该值
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//阈值,当链表中节点数大于该阈值后就会转变成红黑树
static final int TREEIFY_THRESHOLD = 8;

//与上一个阈值相反,当小于这个阈值后转变回链表
static final int UNTREEIFY_THRESHOLD = 6;

// 看源码注释里说是:树的最小的容量,至少是 4 x TREEIFY_THRESHOLD = 32 然后为了避免(resizing 和 treeification thresholds) 设置成64
static final int MIN_TREEIFY_CAPACITY = 64;



//基本哈希容器节点 实现Map.Entry接口
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;//不可变的哈希值,由关键字key得来
final K key;//不可变的关键字
V value;
Node<K,V> next;

Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}

public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }

public final int hashCode() {//Node对象的哈希值,关键字key的hashCode()与值value的hashCode()做异或运算
return Objects.hashCode(key) ^ Objects.hashCode(value);
}

public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}

public final boolean equals(Object o) {//对象相同或同类型且key-value均相同,则返回true
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}

/*
* 构造函数
*/

public HashMap(int initialCapacity, float loadFactor) {//给定初始容量和装载因子,构造一个空的HashMap
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);//根据指定的容量计算容量,因为必须是2的幂次,虽然将该值赋给threshold,但表示的依然是容量,到时候会重新计算阈值
}

public HashMap(int initialCapacity) {//指定初始容量,和默认装载因子0.75构造空HashMap
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap() {//无参,使用默认的初始容量16,和装载因子0.75构造空的HashMap
this.loadFactor = DEFAULT_LOAD_FACTOR;
}

public HashMap(Map<? extends K, ? extends V> m) {//构造一个和给定Map映射相同的HashMap,默认装载因子,初始空间以足够存放给定Map中的映射为准
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
}

注释中给出了相应解释,下面再着重介绍几个参数。

1. table数组

也就是我们之前所说的HashMap实现中的数组,通过对key计算hash后得到相应数组的index,数组中存储着相同index的链表首结点或者红黑树的根节点,结点类型就是上面代码中的Node

2. 容量, 装载因子,阈值

threshold = loadFactor * capcity

由于这样的一个对应关系,这三个变量在HashMap中只有threshold和loadFactor这两个是明确给出来的。在给出初始容量和装载因子的构造函数中我们可以发现,threshold被作为了初始化的容量变量,他将在第一次调用resize()中被使用。

1
2
3
4
5
6
7
8
9
10
11
12
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}

容量的默认值是16,装载因子默认为0.75, 至于为什么要有装载因子这个设定,而不是在table数组满了再扩容,文档中是这么说的

As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put). The expected number of entries in the map and its load factor should be taken into account when setting its initial capacity, so as to minimize the number of rehash operations. If the initial capacity is greater than the maximum number of entries divided by the load factor, no rehash operations will ever occur.

意思总结差不多就是这是时间和空间均衡后的决定。我们也知道hashmap是用空间换时间,0.75这个装载因子能在二者之间达到一个比较好的平衡。反正就是我们不要乱改就好了。

二. 关键方法

1.hash()

在对hashCode()计算hash时具体实现是这样的:

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

他的执行过程如图所示:

其实就是将本身的hashcode的高16位和低16位做了一个异或操作。至于为什么要这么做呢?这里牵扯到了table数组中index的计算。

1
index = (n - 1) & hash

n为数组的长度,而table长度n为2的幂,而计算table数组下标的时候,举个例子,加入n=16,那么n-1=15的二进制表示就是0x0000 1111,可以看出,任何一个2的幂次减1后二进制肯定都是这种形式,它的意义在于,任何一个值和它做&操作,得到的结构肯定都在0~(n-1)之间,也就是说计算出来的下标值肯定数组的合法下标,这种方式由于使用了位运算比单纯的取模更快。

但问题也来了,设计者认为这方法很容易发生碰撞。为什么这么说呢?不妨思考一下,在n - 1为15(0x1111)时,其实散列真正生效的只是低4bit的有效位,当然容易碰撞了。

因此,设计者想了一个顾全大局的方法(综合考虑了速度、作用、质量),就是把高16bit和低16bit异或了一下。设计者还解释到因为现在大多数的hashCode的分布已经很不错了,就算是发生了碰撞也用O(logn)的tree去做了。仅仅异或一下,既减少了系统的开销,也不会造成的因为高位没有参与下标的计算(table长度比较小时),从而引起的碰撞。

2.put

put方法的流程如下:

  1. 如果table数组为空,那么调用resize()方法新建数组
  2. 对key的hashCode()做hash,然后再计算index;
  3. 如果没碰撞直接放到bucket里;
  4. 如果碰撞了,放在以链表的形式存在buckets后;
  5. 如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树;
  6. 如果节点已经存在就替换old value(保证key的唯一性)
  7. 如果bucket满了(超过load factor*current capacity),就要resize
    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
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    public V put(K key, V value) {  
    return putVal(hash(key), key, value, false, true);
    }

    /*
    * 实现Map.put以及相关方法
    * 向map中加入个节点
    * 没有分析onlyIfAbsent和evict
    */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K, V>[] tab;//指向table数组
    Node<K, V> p;//对应下标中的第一个节点,为null说明没有碰撞,不为null代表链表第一个元素或红黑树根节点
    int n, i;//n为table数组的长度,2的幂次; i表示对应的下标index
    if ((tab = table) == null || (n = tab.length) == 0) // 如果table为空即第一次添加元素,则进行初始化
    n = (tab = resize()).length;
    /*
    * 计算下标,根据hash与n计算index
    * 公式:i = (n - 1) & hash;
    */
    // p=table[i]; 对应下标中的第一个节点
    if ((p = tab[i = (n - 1) & hash]) == null) // p为null说明没有碰撞,
    tab[i] = newNode(hash, key, value, null);//直接新建一个节点加入就可以了
    else {// p不为null,说明有碰撞
    Node<K, V> e;//e,代表map中与给定key值相同的节点
    K k;//代表e的key
    // p的关键字与要加入的关键字相同,则p就是要找的e
    if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
    e = p;
    // 如果p的类型是红黑树,则向红黑树中查找e
    else if (p instanceof TreeNode)
    e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
    // 否则就是链表
    else {
    for (int binCount = 0;; ++binCount) {//遍历链表查找e,如果找不到就新建一个
    if ((e = p.next) == null) {// 如果next为null,说明没有找到
    p.next = newNode(hash, key, value, null);// 那么新创建一个节点
    if (binCount >= TREEIFY_THRESHOLD - 1) // 加入节点后如果超出树形化阈值
    treeifyBin(tab, hash);// 则转换为红黑树
    break;
    }
    if (e.hash == hash && // 找到关键字相同的节点,退出循环
    ((k = e.key) == key || (key != null && key.equals(k))))
    break;
    p = e;
    }
    }
    if (e != null) { //e不为null,说明原来存在对应的key,那么返回原来的值
    V oldValue = e.value;// 保留原来的值,用于返回
    if (!onlyIfAbsent || oldValue == null)
    e.value = value;
    afterNodeAccess(e);
    return oldValue;
    }
    }
    //说明新插入了一个节点,返回null
    ++modCount;
    if (++size > threshold) // 超过临界值,则resize
    resize();
    afterNodeInsertion(evict);
    return null;
    }

3. get() 和 containsKey()方法

这两个方法是最常用的,都是根据给定的key值,一个获取对应的value,一个判断是否存在于Map中,在内部这两个方法都会调用一个finall方法,就是getNode(),也就是查找对应key值的节点。

getNode方法的大致过程:

  1. table里的第一个节点,直接命中;
  2. 如果有冲突,则遍历链表或二叉树去查找相同节点
  3. 查找节点时先判断hash值是否相等
  4. 如果hash值相等,再判断key值是否相等
  5. 判断key值相等时,用==或equals或,整个判断条件为:

(e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))

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
public V get(Object key) {  
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
public boolean containsKey(Object key) {
return getNode(hash(key), key) != null;
}
/*
* 实现Map.get以及相关方法
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; //指向table数组
Node<K,V> first, e; //first为table[index],即所在数组下标中第一个节点;e用于遍历节点
int n; K k;//n为table的长度,k用于指向节点的key
if ((tab = table) != null && (n = tab.length) > 0 &&//首先必须保证table数组不为空
(first = tab[(n - 1) & hash]) != null) {//计算下标,保证数组下标中第一个节点不为null不然就肯定找不到直接返回null
if (first.hash == hash && // 先检查第一个节点hash值是否相等
((k = first.key) == key || (key != null && key.equals(k))))//再判断key,如果相等直接返回
return first;
if ((e = first.next) != null) { //第一个不符合,就从下一个开始找
if (first instanceof TreeNode)//红黑树 O(logn)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {//不然就是链表O(n)
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}

4. resize()

从put函数我们不难看出,当table数组为空,或者当加入某个元素后超过阈值,都会调用resize()进行扩容,他的目的就在于将链表和红黑树分散,使得碰撞分散,提高查询效率。简单来说就是下面的步骤:

  1. 将容量和阈值扩大两倍,如果超过最大值就使用最大值最为新的容量和阈值
  2. 新建一个大小为新容量的table,然后将之前的结点放进去

这里有一个很有意思的小技巧,还记得我们的index是怎么计算的吗?

1
index = (n - 1) & hash

假设我们的容量没有超标,由于容量都是2的幂,这里的n扩大2倍,相当于在原来的n-1的基础上高位增加了一个1,说白了就是多取了一位的hash。如图所示

所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置,因此元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。可以看看下图为16扩充为32的resize示意图:

这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
final Node<K,V>[] resize() {  
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
/*
* step1: 先根据容量和阈值确定新的容量和阈值
*/
//case1: 如果table已经被初始化,说明不是第一次加入元素
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {//如果table的容量已经达到最大值,那么就不再扩容了,碰撞也没办法
threshold = Integer.MAX_VALUE;//那么扩大阈值到最大值
return oldTab;//原来的table不变
}
//不然的话table的容量扩大2倍,newCap = oldCap << 1 大部分情况下肯定都是这种情况
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; //阈值也扩大2倍
}
//case2: table没有被初始化,但是阈值大于0,说明在构造函数中指定了容量,但是容量存在阈值那个变量上
else if (oldThr > 0)
newCap = oldThr;//那么将阈值设置为table的容量,下面还会重新计算阈值
//case3: table和阈值都没有初始化,说明是无参构造函数
else {
newCap = DEFAULT_INITIAL_CAPACITY;//使用默认的初始容量
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//计算默认的阈值,threshold=load_factor*capacity
}
//重新计算阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
/*
* step2: 更新阈值和新容量的table
*/
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
/*
* step3: 将原来table中元素,加入到新的table中
*/
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e; //e = oldTab[j]
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null) //e所在位置没有哈希冲突,只有一个元素,直接计算
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) //e所在位置是一颗红黑树
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {// e所在位置是一个链表,则遍历链表
// 根据e.hash & oldCap) == 0,确定放入lo还是hi两个链表
// 其实就是判断e.hash是否大于oldCap
// lo和hi两个链表放分别放在在newTab[j]和newTab[j + oldCap]
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

三. 性能探讨

1.hashcode()

HashMap的查询效率我们理论上看做是O(1),这是在没有发生冲突的情况下,但是当发生冲突较严重的时候,我们会浪费很多的时间在链表的查询或者红黑树的查询,以至于退化为O(n)或者O(logn),所以当作为key的类型,其Hashcode()函数的设计尤为重要。

2. Key的要求

由上一条可知,一个作为key的类型,首先需要有一个设计良好的Hashcode函数,其次我们发现,在get函数中,我们首先判断hashcode相等,再判断equals()或者==来判断是否为同一个对象,因为hashcode相等的两个对象不一定相等,由此可见作为key的另一个条件时重写了equals方法。最后还有一个隐藏条件,key需要为不可变对象比如String,什么叫不可变对象呢?
不可变对象就是创建后状态不能修改的对象,因为只有这样才能确保hashcode不发生变化,才能保证能找到相应的key,总结起来就是一下三个:

  1. 重写hashcode()
  2. 重写equals()
  3. 不可变对象

四. 引用

https://tech.meituan.com/java-hashmap.html
http://paine1690.github.io/2016/11/12/Java/JDK/HashMap%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/
http://yikun.github.io/2015/04/01/Java-HashMap%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86%E5%8F%8A%E5%AE%9E%E7%8E%B0/

缓存穿透、缓存雪崩、hot key

发表于 2018-02-03 | 分类于 数据库 | 阅读次数:

在做排行榜的时候,对缓存的更新频率产生了一定的疑问,在网上也看了不少博客对这方面的介绍,这里对看到的知识做个总结。

缓存穿透

缓存穿透是指查询一个一定不存在的数据,由于缓存是不命中时要查询数据库,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞

解决方法

1. 空值缓存

这是一个比较简单暴力的方法,如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。

缺点
  • 空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间 ( 如果是攻击,问题更严重 ),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。

  • 缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为 5 分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。

2. Bloom Filter

Bloom Filter是一个占用空间很小、效率很高的随机数据结构,它由一个bit数组和一组Hash算法构成。可用于判断一个元素是否在一个集合中,查询效率很高(1-N,最优能逼近于1)。
在很多场景下,我们都需要一个能迅速判断一个元素是否在一个集合中。譬如:

  1. 网页爬虫对URL的去重,避免爬取相同的URL地址;
  2. 反垃圾邮件,从数十亿个垃圾邮件列表中判断某邮箱是否垃圾邮箱(同理,垃圾短信);
  3. 缓存击穿,将已存在的缓存放到布隆中,当黑客访问不存在的缓存时迅速返回避免缓存及DB挂掉。

原理

初始化状态是一个全为0的bit数组

为了表达存储N个元素的集合,使用K个独立的函数来进行哈希运算。x1,x2……xk为k个哈希算法。
如果集合元素有N1,N2……NN,N1经过x1运算后得到的结果映射的位置标1,经过x2运算后结果映射也标1,已经为1的报错1不变。经过k次散列后,对N1的散列完成。
依次对N2,NN等所有数据进行散列,最终得到一个部分为1,部分位为0的字节数组。当然了,这个字节数组会比较长,不然散列效果不好。

那么怎么判断一个外来的元素是否已经在集合里呢,譬如已经散列了10亿个垃圾邮箱,现在来了一个邮箱,怎么判断它是否在这10亿里面呢?
很简单,就拿这个新来的也依次经历x1,x2……xk个哈希算法即可。
在任何一个哈希算法譬如到x2时,得到的映射值有0,那就说明这个邮箱肯定不在这10亿内。
如果是一个黑名单对象,那么可以肯定的是所有映射都为1,肯定跑不了它。也就是说是坏人,一定会被抓。
那么误伤是为什么呢,就是指一些非黑名单对象的值经过k次哈希后,也全部为1,但它确实不是黑名单里的值,这种概率是存在的,但是是可控的。

至于具体实现,可以直接调用com.google.guava中的BloomFilter,就不赘述了。

缓存雪崩

平时我们设定一个缓存的过期时间时,可能有一些会设置1分钟啊,5分钟这些,并发很高时可能会出在某一个时间同时生成了很多的缓存,并且过期时间都一样,这个时候就可能引发一当过期时间到后,这些缓存同时失效,请求全部转发到DB,DB可能会压力过重。

解决方法

  1. 将缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
  2. 加锁或者队列的方式保证缓存的单线 程(进程)写,从而避免失效时大量的并发请求落到底层存储系统上。

热key重建

开发人员使用缓存 + 过期时间的策略既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害:

  • 当前 key 是一个热点 key( 例如一个热门的娱乐新闻),并发量非常大。
  • 重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的 SQL、多次IO、多个依赖等。

在缓存失效的瞬间,有大量线程来重建缓存 ( 如下图),造成后端负载加大,甚至可能会让应用崩溃。

要解决这个问题也不是很复杂,但是不能为了解决这个问题给系统带来更多的麻烦,所以需要制定如下目标:

  • 减少重建缓存的次数
  • 数据尽可能一致
  • 较少的潜在危险

解决方案

1. 互斥锁

此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可。

这种方案思路比较简单,但是存在一定的隐患,如果构建缓存过程出现问题或者时间较长,可能会存在死锁和线程池阻塞的风险,但是这种方法能够较好的降低后端存储负载并在一致性上做的比较好。

2. 不设置超时时间

“永远不过期”包含两层意思:

  • 从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期后产生的问题,也就是“物理”不过期。
  • 从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。

这种方案由于没有设置真正的过期时间,实际上已经不存在热点 key 产生的一系列危害,但是会存在数据不一致的情况,同时代码复杂度会增大。

http://mp.weixin.qq.com/s/TBCEwLVAXdsTszRVpXhVug
http://blog.csdn.net/tianyaleixiaowu/article/details/74721877
http://blog.csdn.net/zeb_perfect/article/details/54135506
https://zhuanlan.zhihu.com/p/26151305

12

MoriatyC

MoriatyC的学习心得

18 日志
9 分类
27 标签
© 2018 MoriatyC
由 Hexo 强力驱动
|
主题 — NexT.Gemini v5.1.4