Archerda's Blog

Programmer. Meditating.


  • 首页

  • 归档

  • 标签

【Repost】淘宝大秒系统设计详解

发表于 2016-03-12
淘宝大秒系统设计详解

Vim命令图

发表于 2016-03-02

Vim命令图


多线程带来的风险

发表于 2016-02-28

概述

Java对线程的支持其实是一把双刃剑。如果使用得当,线程可以有效的降低程序的开发和维护成本,同时提升复杂应用的性能。优点如下:

  1. 发挥多处理器的能力,提高系统吞吐率
  2. 简化建模(例如Servlet和RMI远程方法调用):
    复杂工作简化为一组简单的并且同步的工作流,每个工作流在单独的线程中运行,并在特定的地方进行交互。
  3. 简化异步事件处理
  4. 更灵敏的用户响应界面

然而,线程也带来了一些额外的问题。


安全性问题

安全性的含义是“永远不发生糟糕的事情”。

看下面的代码:

1
2
3
4
5
6
7
8
9
// 非线程安全的数值序列生成器
public class UnsafeSequence {
private int value;
// 返回一个唯一的数值
public int getNext() {
return value++;
}
}

在单线程的环境中,这个类可以正常工作,产生正确的结果,但是在多线程的则不能。

UnsafeSequence的问题在于,如果执行的时间不对,那么两个线程在调用getNext方法时会得到相同的值。看下图:

这种错误出现的情况是:虽然 value++ 看上去是一个原子操作,但事实上它包含了3个操作:读取value、将value的值+1、将结果写入value。由于运行的时候多个线程存在交替执行的情况,因此这两个线程可能同时执行读操作,从而使它们得到相同的值。结果也就是不同的线程的调用返回了相同的值,而这不是我们想要的结果。

这是一个并发安全问题,称为 竞态条件(Race Condition) 。在多线程环境下,getNext是否会返回唯一的数值,要取决于运行时对线程中操作的交替执行方式,这显然不正确。

由于多个线程要共享相同的内存地址空间,并且是并发操作,因此它们可能会访问或修改其他线程正在使用的变量。当然,这是一种极大的便利,因为这种方式比其他线程间通讯机制更容易实现数据共享。但是它同样带来了巨大的风险:线程由于无法预料数据变化而发生错误,当多个线程同时访问或修改相同的变量时,将会在串行编程模型中引入非串行因素,而非串行性是很难分析的。要使多线程程序的行为进行预测,必须对共享变量的访问操作进行协同,这样才不会在线程之间发生干扰。幸运的是,Java提供了各种同步机制来协同这种访问。

看下面代码:

1
2
3
4
5
6
7
8
9
// 线程安全的数值序列生成器
public class SafeSequence {
private int value;
// 返回一个唯一的数值
public synchronized int getNext() {
return value++;
}
}

我们将getNext修改为一个同步的方法,修复竟态条件问题。

在开发并发代码时,一定要注意 线程安全性是不可破坏的。


活跃性问题

活跃性的含义是“某件正确的事情一定会发生”。

当某个操作无法继续执行下去的时候,就会发生活跃性问题。在串行程序中,活跃性问题就是无意中造成的无限循环,从而使得循环之后的代码无法得到执行。线程将带来其他一些活跃性问题。

比如,线程A在等待线程B释放所持用的资源,而线程B永远都不释放持有的资源,那么A就会永久地等下去。就是一种 饥饿现象,除此之外还有死锁以及活锁。

与大多数并发性错误一样,导致活跃性问题的错误同样是难以分析的,因为它们依赖于不同线程时间发生的时序。


性能问题

性能问题的含义是“正确的事情能尽快发生”。

活跃性意味着某件正确的事情最终一定会发生,但却不够好,因为我们同城希望正确的事情尽快发生。性能问题包括多个方面,例如服务时间过长、响应不灵敏、吞吐率过低、资源消耗过高、可伸缩性较低等。

在设计良好的并发应用程序中,线程能提升程序的性能。但无论如何,线程总会带来某种程度的运行时开销。在多线程程序中,但线程调度器临时挂起并转而运行另一个线程时,就会频繁地出现 上下文切换(Context Switch),这种操作将带来极大的开销。当线程共享数据时,必须使用同步机制,而这些同步机制往往会压抑某些编译器优化,使内存缓冲区中的数据无效,以及增加共享内存总线的同步流量。所有这些操作都将带来额外开销。


参考文档

  • 《Java并发编程实战》Brain Goetz等著 机械工业出版社

编程之法-字符串的旋转

发表于 2015-12-24

描述

给定一个字符串,要求将字符串前面的若干个字符移到字符串的尾部。例如,将字符串“abcdef”的前三个字母‘a’、‘b’和‘c’移到字符串的尾部,那么原字符串将编程“defabc”。请编写一个函数实现此功能。

解法一:蛮力移位

思路

将需要移动的字符一个一个地移动到字符的最后。编写一个leftShiftOne方法,每次将一个字符移动到字符串尾部,然后调用m次这个方法,使得字符串开头的m个字符移到字符串的尾部。

代码

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
public class Solution {
public static void main(String[] args) {
char[] s = new char[]{'a', 'b', 'c', 'd', 'e', 'f'};
leftShiftString(s, s.length, 3);
System.out.println(s);
}
//移动首部m位字符到最后
private static void leftShiftString(char[] s, int n, int m) {
while (m > 0) {
leftShiftOne(s, n);
--m;
}
}
//移动首位字符到最后
private static void leftShiftOne(char[] s, int n) {
char t = s[0];
for (int i = 1; i < n; ++i) {
s[i - 1] = s[i];
}
s[n - 1] = t;
}
}

分析

针对长度为n的字符串来说,假设需要移动m个字符到字符串的末尾,那么总共需要m*n次操作,同时设立一个变量保存第一个字符。因此时间复杂度是O(mn), 空间复杂度是O(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
public class Solution2 {
public static void main(String[] args) {
char[] s = new char[]{'a', 'b', 'c', 'd', 'e', 'f'};
leftRotateString(s, s.length, 3);
System.out.println(s);
}
//字符串旋转
private static void leftRotateString(char[] s, int n, int m) {
m %= n; //若要左移动大于n位,那么与移动%n是等价的;
reverseString(s, 0, m - 1);
reverseString(s, m, n - 1);
reverseString(s, 0, n - 1);
}
//字符串反转
private static void reverseString(char[] s, int from, int to) {
while (from < to) {
char t = s[from];
s[from++] = s[to];
s[to--] = t;
}
}
}

分析

这个方法的时间复杂度为O(n),空间复杂度为O(1)。

举一反三:单词反转

描述

输入一个英文句子,翻转句子中单词的顺序。要求单词内字符的顺序保持不变,句子中单词以空格隔开。为简单起见,标点符号和普通字符一样处理。例如,若输入”I am a student.”,则输出”student. a am I”.

思路

先反转每一个单词,再反转整个字符串。

代码

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
public class Solution3 {
public static void main(String[] args) {
String str = "My name is luohuida!";
char[] chs = leftRotateString(str.toCharArray(), str.length());
System.out.println(chs);
}
//单词翻转
private static char[] leftRotateString(char[] s, int n) {
int i = 0;
int j = 0;
//先翻转每个单词
for (; j < n; ++j) {
if (j == n - 1 || s[j+1] == ' ') {
reverseString(s, i, j);
i = j + 2;
}
}
reverseString(s, 0, n - 1);
return s;
}
//字符串反转
private static void reverseString(char[] s, int from, int to) {
while (from < to) {
char t = s[from];
s[from++] = s[to];
s[to--] = t;
}
}
}

分析

时间复杂度O(nlogn),空间复杂度O(1)。

参考文档

  • 《编程之法》

【Repost】设计模式-六大设计原则

发表于 2015-12-05

本文转载自开发技术前线:面向对象六大原则
本文转载自开发技术前线:面向对象六大原则
本文转载自开发技术前线:面向对象六大原则

优化代码的第一步——单一职责原则

单一职责原则的英文名称是Single Responsibility Principle,简称SRP。它的定义是:就一个类而言,应该仅有一个引起它变化的原因。简单来说,一个类中应该是一组相关性很高的函数、数据的封装。就像秦小波老师在《设计模式之禅》中说的:“这是一个备受争议却又及其重要的原则。只要你想和别人争执、怄气或者是吵架,这个原则是屡试不爽的”。因为单一职责的划分界限并不是总是那么清晰,很多时候都是需要靠个人经验来界定。当然,最大的问题就是对职责的定义,什么是类的职责,以及怎么划分类的职责。
对于计算机技术,通常只单纯地学习理论知识并不能很好地领会其深意,只有自己动手实践,并在实际运用中发现问题、解决问题、思考问题,才能够将知识吸收到自己的脑海中。下面以我的朋友小民的事迹说起。

自从Android系统发布以来,小民就是Android的铁杆粉丝,于是在大学期间一直保持着对Android的关注,并且利用课余时间做些小项目,锻炼自己的实战能力。毕业后,小民如愿地加入了心仪的公司,并且投入到了他热爱的Android应用开发行业中。将爱好、生活、事业融为一体,小民的第一份工作也算是顺风顺水,一切尽在掌握中。
在经历过一周的适应期以及熟悉公司的产品、开发规范之后,小民的开发工作就正式开始了。小民的主管是个工作经验丰富的技术专家,对于小民的工作并不是很满意,尤其小民最薄弱的面向对象设计,而Android开发又是使用Java语言,什么抽象、接口、六大原则、23种设计模式等名词把小民弄得晕头转向。小民自己也察觉到了自己的问题所在,于是,小民的主管决定先让小民做一个小项目来锻炼锻炼这方面的能力。正所谓养兵千日用兵一时,磨刀不误砍柴工,小民的开发之路才刚刚开始。

在经过一番思考之后,主管挑选了使用范围广、难度也适中的ImageLoader(图片加载)作为小民的训练项目。既然要训练小民的面向对象设计,那么就必须考虑到可扩展性、灵活性,而检测这一切是否符合需求的最好途径就是开源。用户不断地提出需求、反馈问题,小民的项目需要不断升级以满足用户需求,并且要保证系统的稳定性、灵活性。在主管跟小民说了这一特殊任务之后,小民第一次感到了压力,“生活不容易呐!”年仅22岁至今未婚的小民发出了如此深刻的感叹!

挑战总是要面对的,何况是从来不服输的小民。主管的要求很简单,要小民实现图片加载,并且要将图片缓存起来。在分析了需求之后,小民一下就放心下来了,“这么简单,原来我还以为很难呢……”小民胸有成足的喃喃自语。在经历了十分钟的编码之后,小民写下了如下代码:

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
/**
* 图片加载类
*/
public class ImageLoader {
// 图片缓存
LruCache<String, Bitmap> mImageCache;
// 线程池,线程数量为CPU的数量
ExecutorService mExecutorService = Executors.newFixedThreadPool (Runtime.getRuntime().availableProcessors());
public ImageLoader() {
initImageCache();
}
private void initImageCache() {
// 计算可使用的最大内存
final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
// 取四分之一的可用内存作为缓存
final int cacheSize = maxMemory / 4;
mImageCache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap bitmap) {
return bitmap.getRowBytes() * bitmap.getHeight() / 1024;
}
};
}
public void displayImage(final String url, final ImageView imageView) {
imageView.setTag(url);
mExecutorService.submit(new Runnable() {
@Override
public void run() {
Bitmap bitmap = downloadImage(url);
if (bitmap == null) {
return;
}
if (imageView.getTag().equals(url)) {
imageView.setImageBitmap(bitmap);
}
mImageCache.put(url, bitmap);
}
});
}
public Bitmap downloadImage(String imageUrl) {
Bitmap bitmap = null;
try {
URL url = newURL(imageUrl);
final HttpURLConnection conn = (HttpURLConnection)
url.openConnection();
bitmap = BitmapFactory.decodeStream(conn.getInputStream());
conn.disconnect();
} catch (Exception e) {
e.printStackTrace();
}
return bitmap;
}
}

并且使用git软件进行版本控制,将工程托管到github上,伴随着git push命令的完成,小民的ImageLoader 0.1版本就正式发布了!如此短的时间内就完成了这个任务,而且还是一个开源项目,小民暗暗自喜,幻想着待会儿主管的称赞。

在小民给主管报告了ImageLoader的发布消息的几分钟之后,主管就把小民叫到了会议室。这下小民纳闷了,怎么夸人还需要到会议室。“小民,你的ImageLoader耦合太严重啦!简直就没有设计可言,更不要说扩展性、灵活性了。所有的功能都写在一个类里怎么行呢,这样随着功能的增多,ImageLoader类会越来越大,代码也越来越复杂,图片加载系统就越来越脆弱……”Duang,这简直就是当头棒喝,小民的脑海里已经听不清主管下面说的内容了,只是觉得自己之前没有考虑清楚就匆匆忙忙完成任务,而且把任务想得太简单了。

“你还是把ImageLoader拆分一下,把各个功能独立出来,让它们满足单一职责原则。”主管最后说道。小民是个聪明人,敏锐地捕捉到了单一职责原则这个关键词。用Google搜索了一些优秀资料之后总算是对单一职责原则有了一些认识。于是打算对ImageLoader进行一次重构。这次小民不敢过于草率,也是先画了一幅UML图,如图1-1所示。
图1-1
ImageLoader代码修改如下所示:

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
/**
* 图片加载类
*/
public class ImageLoader {
// 图片缓存
ImageCache mImageCache = new ImageCache() ;
// 线程池,线程数量为CPU的数量
ExecutorService mExecutorService = Executors.newFixedThreadPool (Runtime.getRuntime().availableProcessors());
// 加载图片
public void displayImage(final String url, final ImageView imageView) {
Bitmap bitmap = mImageCache.get(url);
if (bitmap != null) {
imageView.setImageBitmap(bitmap);
return;
}
imageView.setTag(url);
mExecutorService.submit(new Runnable() {
@Override
public void run() {
Bitmap bitmap = downloadImage(url);
if (bitmap == null) {
return;
}
if (imageView.getTag().equals(url)) {
imageView.setImageBitmap(bitmap);
}
mImageCache.put(url, bitmap);
}
});
}
public Bitmap downloadImage(String imageUrl) {
Bitmap bitmap = null;
try {
URL url = new URL(imageUrl);
final HttpURLConnection conn = (HttpURLConnection)
url.openConnection();
bitmap = BitmapFactory.decodeStream(conn.getInputStream());
conn.disconnect();
} catch (Exception e) {
e.printStackTrace();
}
return bitmap;
}
}

并且添加了一个ImageCache类用于处理图片缓存,具体代码如下:

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
public class ImageCache {
// 图片LRU缓存
LruCache<String, Bitmap> mImageCache;
public ImageCache() {
initImageCache();
}
private void initImageCache() {
// 计算可使用的最大内存
final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
// 取四分之一的可用内存作为缓存
final int cacheSize = maxMemory / 4;
mImageCache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap bitmap) {
return bitmap.getRowBytes() * bitmap.getHeight() / 1024;
}
};
}
public void put(String url, Bitmap bitmap) {
mImageCache.put(url, bitmap) ;
}
public Bitmap get(String url) {
return mImageCache.get(url) ;
}
}

如图1-1和上述代码所示,小民将ImageLoader一拆为二,ImageLoader只负责图片加载的逻辑,而ImageCache只负责处理图片缓存的逻辑,这样ImageLoader的代码量变少了,职责也清晰了,当与缓存相关的逻辑需要改变时,不需要修改ImageLoader类,而图片加载的逻辑需要修改时也不会影响到缓存处理逻辑。主管在审核了小民的第一次重构之后,对小民的工作给予了表扬,大致意思是结构变得清晰了许多,但是可扩展性还是比较欠缺,虽然没有得到主管的完全肯定,但也是颇有进步,再考虑到自己确实有所收获,小民原本沮丧的心里也略微地好转起来。

从上述的例子中我们能够体会到,单一职责所表达出的用意就是“单一”二字。正如上文所说,如何划分一个类、一个函数的职责,每个人都有自己的看法,这需要根据个人经验、具体的业务逻辑而定。但是,它也有一些基本的指导原则,例如,两个完全不一样的功能就不应该放在一个类中。一个类中应该是一组相关性很高的函数、数据的封装。工程师可以不断地审视自己的代码,根据具体的业务、功能对类进行相应的拆分,我想这会是你优化代码迈出的第一步。

让程序更稳定、更灵活——开闭原则

开闭原则的英文全称是Open Close Principle,简称OCP,它是Java世界里最基础的设计原则,它指导我们如何建立一个稳定的、灵活的系统。开闭原则的定义是:软件中的对象(类、模块、函数等)应该对于扩展是开放的,但是,对于修改是封闭的。在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会将错误引入原本已经经过测试的旧代码中,破坏原有系统。因此,当软件需要变化时,我们应该尽量通过扩展的方式来实现变化,而不是通过修改已有的代码来实现。当然,在现实开发中,只通过继承的方式来升级、维护原有系统只是一个理想化的愿景,因此,在实际的开发过程中,修改原有代码、扩展代码往往是同时存在的。

软件开发过程中,最不会变化的就是变化本身。产品需要不断地升级、维护,没有一个产品从第一版本开发完就再没有变化了,除非在下个版本诞生之前它已经被终止。而产品需要升级,修改原来的代码就可能会引发其他的问题。那么如何确保原有软件模块的正确性,以及尽量少地影响原有模块,答案就是尽量遵守本章要讲述的开闭原则。

勃兰特·梅耶在1988年出版的《面向对象软件构造》一书中提出这一原则。这一想法认为,一旦完成,一个类的实现只应该因错误而被修改,新的或者改变的特性应该通过新建不同的类实现。新建的类可以通过继承的方式来重用原类的代码。显然,梅耶的定义提倡实现继承,已存在的实现对于修改是封闭的,但是新的实现类可以通过覆写父类的接口应对变化。
说了这么多,想必大家还是半懂不懂,还是让我们以一个简单示例说明一下吧。

在对ImageLoader进行了一次重构之后,小民的这个开源库获得了一些用户。小民第一次感受到自己发明“轮子”的快感,对开源的热情也越发高涨起来!通过动手实现一些开源库来深入学习相关技术,不仅能够提升自我,也能更好地将这些技术运用到工作中,从而开发出更稳定、优秀的应用,这就是小民的真实想法。

小民第一轮重构之后的ImageLoader职责单一、结构清晰,不仅获得了主管的一点肯定,还得到了用户的夸奖,算是个不错的开始。随着用户的增多,有些问题也暴露出来了,小民的缓存系统就是大家“吐槽”最多的地方。通过内存缓存解决了每次从网络加载图片的问题,但是,Android应用的内存很有限,且具有易失性,即当应用重新启动之后,原来已经加载过的图片将会丢失,这样重启之后就需要重新下载!这又会导致加载缓慢、耗费用户流量的问题。小民考虑引入SD卡缓存,这样下载过的图片就会缓存到本地,即使重启应用也不需要重新下载了!小民在和主管讨论了该问题之后就投入了编程中,下面就是小民的代码。
DiskCache.java类,将图片缓存到SD卡中:

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
public class DiskCache {
// 为了简单起见临时写个路径,在开发中请避免这种写法 !
static String cacheDir = "sdcard/cache/";
// 从缓存中获取图片
public Bitmap get(String url) {
return BitmapFactory.decodeFile(cacheDir + url);
}
// 将图片缓存到内存中
public void put(String url, Bitmap bmp) {
FileOutputStream fileOutputStream = null;
try {
fileOutputStream = new FileOutputStream(cacheDir + url);
bmp.compress(CompressFormat.PNG, 100, fileOutputStream);
} catch (FileNotFoundException e) {
e.printStackTrace();
} final ly {
if (fileOutputStream != null) {
try {
fileOutputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}

因为需要将图片缓存到SD卡中,所以,ImageLoader代码有所更新,具体代码如下:

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
public class ImageLoader {
// 内存缓存
ImageCache mImageCache = new ImageCache();
// SD卡缓存
DiskCache mDiskCache = new DiskCache();
// 是否使用SD卡缓存
boolean isUseDiskCache = false;
// 线程池,线程数量为CPU的数量
ExecutorService mExecutorService = Executors.newFixedThreadPool (Runtime.getRuntime().availableProcessors());
public void displayImage(final String url, final ImageView imageView) {
// 判断使用哪种缓存
Bitmap bitmap = isUseDiskCache ? mDiskCache.get(url)
: mImageCache.get (url);
if (bitmap != null) {
imageView.setImageBitmap(bitmap);
return;
}
// 没有缓存,则提交给线程池进行下载
}
public void useDiskCache(boolean useDiskCache) {
isUseDiskCache = useDiskCache ;
}
}

从上述的代码中可以看到,仅仅新增了一个DiskCache类和往ImageLoader类中加入了少量代码就添加了SD卡缓存的功能,用户可以通过useDiskCache方法来对使用哪种缓存进行设置,例如:

1
2
3
4
5
ImageLoader imageLoader = new ImageLoader() ;
// 使用SD卡缓存
imageLoader.useDiskCache(true);
// 使用内存缓存
imageLoader.useDiskCache(false);

通过useDiskCache方法可以让用户设置不同的缓存,非常方便啊!小民对此很满意,于是提交给主管做代码审核。“小民,你思路是对的,但是有些明显的问题,就是使用内存缓存时用户就不能使用SD卡缓存,类似的,使用SD卡缓存时用户就不能使用内存缓存。用户需要这两种策略的综合,首先缓存优先使用内存缓存,如果内存缓存没有图片再使用SD卡缓存,如果SD卡中也没有图片最后才从网络上获取,这才是最好的缓存策略。”主管真是一针见血,小民这时才如梦初醒,刚才还得意洋洋的脸上突然有些泛红……
于是小民按照主管的指点新建了一个双缓存类DoudleCache,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 双缓存。获取图片时先从内存缓存中获取,如果内存中没有缓存该图片,再从SD卡中获取。
* 缓存图片也是在内存和SD卡中都缓存一份
*/
public class DoubleCache {
ImageCache mMemoryCache = new ImageCache();
DiskCache mDiskCache = new DiskCache();
// 先从内存缓存中获取图片,如果没有,再从SD卡中获取
public Bitmap get(String url) {
Bitmap bitmap = mMemoryCache.get(url);
if (bitmap == null) {
bitmap = mDiskCache.get(url);
}
return bitmap;
}
// 将图片缓存到内存和SD卡中
public void put(String url, Bitmap bmp) {
mMemoryCache.put(url, bmp);
mDiskCache.put(url, bmp);
}
}

我们再看看最新的ImageLoader类吧,代码更新也不多:

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
public class ImageLoader {
// 内存缓存
ImageCache mImageCache = new ImageCache();
// SD卡缓存
DiskCache mDiskCache = new DiskCache();
// 双缓存
DoubleCache mDoubleCache = new DoubleCache() ;
// 使用SD卡缓存
boolean isUseDiskCache = false;
// 使用双缓存
boolean isUseDoubleCache = false;
// 线程池,线程数量为CPU的数量
ExecutorService mExecutorService = Executors.newFixedThreadPool (Runtime.getRuntime().availableProcessors());
public void displayImage(final String url, final ImageView imageView) {
Bitmap bmp = null;
if (isUseDoubleCache) {
bmp = mDoubleCache.get(url);
} else if (isUseDiskCache) {
bmp = mDiskCache.get(url);
} else {
bmp = mImageCache.get(url);
}
if ( bmp != null ) {
imageView.setImageBitmap(bmp);
}
// 没有缓存,则提交给线程池进行异步下载图片
}
public void useDiskCache(boolean useDiskCache) {
isUseDiskCache = useDiskCache ;
}
public void useDoubleCache(boolean useDoubleCache) {
isUseDoubleCache = useDoubleCache ;
}
}

通过增加短短几句代码和几处修改就完成了如此重要的功能。小民已越发觉得自己Android开发已经到了的得心应手的境地,不仅感觉一阵春风袭来,他那飘逸的头发一下从他的眼前拂过,小民感觉今天天空比往常敞亮许多。

“小民,你每次加新的缓存方法时都要修改原来的代码,这样很可能会引入Bug,而且会使原来的代码逻辑变得越来越复杂,按照你这样的方法实现,用户也不能自定义缓存实现呀!”到底是主管水平高,一语道出了小民这缓存设计上的问题。

我们还是来分析一下小民的程序,小民每次在程序中加入新的缓存实现时都需要修改ImageLoader类,然后通过一个布尔变量来让用户使用哪种缓存,因此,就使得在ImageLoader中存在各种if-else判断,通过这些判断来确定使用哪种缓存。随着这些逻辑的引入,代码变得越来越复杂、脆弱,如果小民一不小心写错了某个if条件(条件太多,这是很容易出现的),那就需要更多的时间来排除。整个ImageLoader类也会变得越来越臃肿。最重要的是用户不能自己实现缓存注入到ImageLoader中,可扩展性可是框架的最重要特性之一。

“软件中的对象(类、模块、函数等)应该对于扩展是开放的,但是对于修改是封闭的,这就是开放-关闭原则。也就是说,当软件需要变化时,我们应该尽量通过扩展的方式来实现变化,而不是通过修改已有的代码来实现。”小民的主管补充到,小民听得云里雾里的。主管看小民这等反应,于是亲自“操刀”,为他画下了如图1-2的UML图。
图1-2
小民看到图1-2似乎明白些什么,但是又不是太明确如何修改程序。主管看到小民这般模样只好亲自上阵,带着小民把ImageLoader程序按照图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
46
47
48
49
50
51
52
53
54
55
public class ImageLoader {
// 图片缓存
ImageCache mImageCache = new MemoryCache();
// 线程池,线程数量为CPU的数量
ExecutorService mExecutorService = Executors.newFixedThreadPool (Runtime.getRuntime().availableProcessors());
// 注入缓存实现
public void setImageCache(ImageCache cache) {
mImageCache = cache;
}
public void displayImage(String imageUrl, ImageView imageView) {
Bitmap bitmap = mImageCache.get(imageUrl);
if (bitmap != null) {
imageView.setImageBitmap(bitmap);
return;
}
// 图片没缓存,提交到线程池中下载图片
submitLoadRequest(imageUrl, imageView);
}
private void submitLoadRequest(final String imageUrl,
final ImageView imageView) {
imageView.setTag(imageUrl);
mExecutorService.submit(new Runnable() {
@Override
public void run() {
Bitmap bitmap = downloadImage(imageUrl);
if (bitmap == null) {
return;
}
if (imageView.getTag().equals(imageUrl)) {
imageView.setImageBitmap(bitmap);
}
mImageCache.put(imageUrl, bitmap);
}
});
}
public Bitmap downloadImage(String imageUrl) {
Bitmap bitmap = null;
try {
URL url = new URL(imageUrl);
final HttpURLConnection conn = (HttpURLConnection)
url.openConnection();
bitmap = BitmapFactory.decodeStream(conn.getInputStream());
conn.disconnect();
} catch (Exception e) {
e.printStackTrace();
}
return bitmap;
}
}

经过这次重构,没有了那么多的if-else语句,没有了各种各样的缓存实现对象、布尔变量,代码确实清晰、简单了很多,小民对主管的崇敬之情又“泛滥”了起来。需要注意的是,这里的ImageCache类并不是小民原来的那个ImageCache,这次程序重构主管把它提取成一个图片缓存的接口,用来抽象图片缓存的功能。我们看看该接口的声明:

1
2
3
4
public interface ImageCache {
public Bitmap get(String url);
public void put(String url, Bitmap bmp);
}

ImageCache接口简单定义了获取、缓存图片两个函数,缓存的key是图片的url,值是图片本身。内存缓存、SD卡缓存、双缓存都实现了该接口,我们看看这几个缓存实现:

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
// 内存缓存MemoryCache类
public class MemoryCache implements ImageCache {
private LruCache<String, Bitmap> mMemeryCache;
public MemoryCache() {
// 初始化LRU缓存
}
@Override
public Bitmap get(String url) {
return mMemeryCache.get(url);
}
@Override
public void put(String url, Bitmap bmp) {
mMemeryCache.put(url, bmp);
}
}
// SD卡缓存DiskCache类
public class DiskCache implements ImageCache {
@Override
public Bitmap get(String url) {
return null/* 从本地文件中获取该图片 */;
}
@Override
public void put(String url, Bitmap bmp) {
// 将Bitmap写入文件中
}
}
// 双缓存DoubleCache类
public class DoubleCache implements ImageCache{
ImageCache mMemoryCache = new MemoryCache();
ImageCache mDiskCache = new DiskCache();
// 先从内存缓存中获取图片,如果没有,再从SD卡中获取
public Bitmap get(String url) {
Bitmap bitmap = mMemoryCache.get(url);
if (bitmap == null) {
bitmap = mDiskCache.get(url);
}
return bitmap;
}
// 将图片缓存到内存和SD卡中
public void put(String url, Bitmap bmp) {
mMemoryCache.put(url, bmp);
mDiskCache.put(url, bmp);
}
}

细心的朋友可能注意到了,ImageLoader类中增加了一个setImageCache(ImageCache cache)函数,用户可以通过该函数设置缓存实现,也就是通常说的依赖注入。下面就看看用户是如何设置缓存实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ImageLoader imageLoader = new ImageLoader() ;
// 使用内存缓存
imageLoader.setImageCache(new MemoryCache());
// 使用SD卡缓存
imageLoader.setImageCache(new DiskCache());
// 使用双缓存
imageLoader.setImageCache(new DoubleCache());
// 使用自定义的图片缓存实现
imageLoader.setImageCache(new ImageCache() {
@Override
public void put(String url, Bitmap bmp) {
// 缓存图片
}
@Override
public Bitmap get(String url) {
return null/*从缓存中获取图片*/;
}
});

在上述代码中,通过setImageCache(ImageCache cache)方法注入不同的缓存实现,这样不仅能够使ImageLoader更简单、健壮,也使得ImageLoader的可扩展性、灵活性更高。MemoryCache、DiskCache、DoubleCache缓存图片的具体实现完全不一样,但是,它们的一个特点是都实现了ImageCache接口。当用户需要自定义实现缓存策略时,只需要新建一个实现ImageCache接口的类,然后构造该类的对象,并且通过setImageCache(ImageCache cache)注入到ImageLoader中,这样ImageLoader就实现了变化万千的缓存策略,而扩展这些缓存策略并不会导致ImageLoader类的修改。经过这次重构,小民的ImageLoader已经基本算合格了。咦!这不就是主管说的开闭原则么!“软件中的对象(类、模块、函数等)应该对于扩展是开放的,但是对于修改是封闭的。而遵循开闭原则的重要手段应该是通过抽象……”小民细声细语的念叨中,陷入了思索中……

开闭原则指导我们,当软件需要变化时,应该尽量通过扩展的方式来实现变化,而不是通过修改已有的代码来实现。这里的“应该尽量”4个字说明OCP原则并不是说绝对不可以修改原始类的,当我们嗅到原来的代码“腐化气味”时,应该尽早地重构,以使得代码恢复到正常的“进化”轨道,而不是通过继承等方式添加新的实现,这会导致类型的膨胀以及历史遗留代码的冗余。我们的开发过程中也没有那么理想化的状况,完全地不用修改原来的代码,因此,在开发过程中需要自己结合具体情况进行考量,是通过修改旧代码还是通过继承使得软件系统更稳定、更灵活,在保证去除“代码腐化”的同时,也保证原有模块的正确性。

构建扩展性更好的系统——里氏替换原则

里氏替换原则英文全称是Liskov Substitution Principle,简称LSP。它的第一种定义是:如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代换成o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型。上面这种描述确实不太好理解,理论家有时候容易把问题抽象化,本来挺容易理解的事让他们一概括就弄得拗口了。我们再看看另一个直截了当的定义。里氏替换原则第二种定义:所有引用基类的地方必须能透明地使用其子类的对象。

我们知道,面向对象的语言的三大特点是继承、封装、多态,里氏替换原则就是依赖于继承、多态这两大特性。里氏替换原则简单来说就是,所有引用基类的地方必须能透明地使用其子类的对象。通俗点讲,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。但是,反过来就不行了,有子类出现的地方,父类未必就能适应。说了那么多,其实最终总结就两个字:抽象。
小民为了深入地了解Android中的Window与View的关系特意写了一个简单示例,为了便于理解,我们先看如图1-3所示。
图1-3
我们看看具体的代码:

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
// 窗口类
public class Window {
public void show(View child){
child.draw();
}
}
// 建立视图抽象,测量视图的宽高为公用代码,绘制交给具体的子类
public abstract class View {
public abstract void draw() ;
public void measure(int width, int height){
// 测量视图大小
}
}
// 按钮类的具体实现
public class Button extends View {
public void draw(){
// 绘制按钮
}
}
// TextView的具体实现
public class TextView extends View {
public void draw(){
// 绘制文本
}
}

上述示例中,Window依赖于View,而View定义了一个视图抽象,measure是各个子类共享的方法,子类通过覆写View的draw方法实现具有各自特色的功能,在这里,这个功能就是绘制自身的内容。任何继承自View类的子类都可以设置给show方法,也就我们所说的里氏替换。通过里氏替换,就可以自定义各式各样、千变万化的View,然后传递给Window,Window负责组织View,并且将View显示到屏幕上。
里氏替换原则的核心原理是抽象,抽象又依赖于继承这个特性,在OOP当中,继承的优缺点都相当明显。
优点如下:

  • (1)代码重用,减少创建类的成本,每个子类都拥有父类的方法和属性;
  • (2)子类与父类基本相似,但又与父类有所区别;
  • (3)提高代码的可扩展性。

继承的缺点:

  • 继承是侵入性的,只要继承就必须拥有父类的所有属性和方法;
  • 可能造成子类代码冗余、灵活性降低,因为子类必须拥有父类的属性和方法。

事物总是具有两面性,如何权衡利与弊都是需要根据具体场景来做出选择并加以处理。里氏替换原则指导我们构建扩展性更好的软件系统,我们还是接着上面的ImageLoader来做说明。
上文的图1-2也很好地反应了里氏替换原则,即MemoryCache、DiskCache、DoubleCache都可以替换ImageCache的工作,并且能够保证行为的正确性。ImageCache建立了获取缓存图片、保存缓存图片的接口规范,MemoryCache等根据接口规范实现了相应的功能,用户只需要在使用时指定具体的缓存对象就可以动态地替换ImageLoader中的缓存策略。这就使得ImageLoader的缓存系统具有了无线的可能性,也就是保证了可扩展性。

想象一个场景,当ImageLoader中的setImageCache(ImageCache cache)中的cache对象不能够被子类所替换,那么用户如何设置不同的缓存对象以及用户如何自定义自己的缓存实现,通过1.3节中的useDiskCache方法吗?显然不是的,里氏替换原则就为这类问题提供了指导原则,也就是建立抽象,通过抽象建立规范,具体的实现在运行时替换掉抽象,保证系统的高扩展性、灵活性。开闭原则和里氏替换原则往往是生死相依、不弃不离的,通过里氏替换来达到对扩展开放,对修改关闭的效果。然而,这两个原则都同时强调了一个OOP的重要特性——抽象,因此,在开发过程中运用抽象是走向代码优化的重要一步。

让项目拥有变化的能力——依赖倒置原则

依赖倒置原则英文全称是Dependence Inversion Principle,简称DIP。依赖反转原则指代了一种特定的解耦形式,使得高层次的模块不依赖于低层次的模块的实现细节的目的,依赖模块被颠倒了。这个概念有点不好理解,这到底是什么意思呢?
依赖倒置原则的几个关键点:

  • 高层模块不应该依赖低层模块,两者都应该依赖其抽象;
  • 抽象不应该依赖细节;
  • 细节应该依赖抽象。

在Java语言中,抽象就是指接口或抽象类,两者都是不能直接被实例化的;细节就是实现类,实现接口或继承抽象类而产生的类就是细节,其特点就是,可以直接被实例化,也就是可以加上一个关键字 new 产生一个对象。高层模块就是调用端,低层模块就是具体实现类。依赖倒置原则在 Java 语言中的表现就是:模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的。这又是一个将理论抽象化的实例,其实一句话就可以概括:面向接口编程,或者说是面向抽象编程,这里的抽象指的是接口或者抽象类。面向接口编程是面向对象精髓之一,也就是上面两节强调的抽象。

如果在类与类直接依赖于细节,那么它们之间就有直接的耦合,当具体实现需要变化时,意味着在这要同时修改依赖者的代码,并且限制了系统的可扩展性。我们看1.3节的图1-3中,ImageLoader直接依赖于MemoryCache,这个MemoryCache是一个具体实现,而不是一个抽象类或者接口。这导致了ImageLoader直接依赖了具体细节,当MemoryCache不能满足ImageLoader而需要被其他缓存实现替换时,此时就必须修改ImageLoader的代码,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ImageLoader {
// 内存缓存 ( 直接依赖于细节 )
MemoryCache mMemoryCache = new MemoryCache();
// 加载图片到ImageView中
public void displayImage(String url, ImageView imageView) {
Bitmap bmp = mMemoryCache.get(url);
if (bmp == null) {
downloadImage(url, imageView);
} else {
imageView.setImageBitmap(bmp);
}
}
public void setImageCache(MemoryCache cache) {
mCache = cache ;
}
// 代码省略
}

随着产品的升级,用户发现MemoryCache已经不能满足需求,用户需要小民的ImageLoader可以将图片同时缓存到内存和SD卡中,或者可以让用户自定义实现缓存。此时,我们的MemoryCache这个类名不仅不能够表达内存缓存和SD卡缓存的意义,也不能够满足功能。另外,用户需要自定义缓存实现时还必须继承自MemoryCache,而用户的缓存实现可不一定与内存缓存有关,这在命名上的限制也让用户体验不好。重构的时候到了!小民的第一种方案是将MemoryCache修改为DoubleCache,然后在DoubleCache中实现具体的缓存功能。我们需要将ImageLoader修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ImageLoader {
// 双缓存 ( 直接依赖于细节 )
DoubleCache mCache = new DoubleCache();
// 加载图片到ImageView中
public void displayImage(String url, ImageView imageView) {
Bitmap bmp = mCache.get(url);
if (bmp == null) {
// 异步下载图片
downloadImageAsync(url, imageView);
} else {
imageView.setImageBitmap(bmp);
}
}
public void setImageCache(DoubleCache cache) {
mCache = cache ;
}
// 代码省略
}

我们将MemoryCache修改成DoubleCache,然后修改了ImageLoader中缓存类的具体实现,轻轻松松就满足了用户需求。等等!这不还是依赖于具体的实现类(DoubleCache)吗?当用户的需求再次变化时,我们又要通过修改缓存实现类和ImageLoader代码来实现?修改原有代码不是违反了1.3节中的开闭原则吗?小民突然醒悟了过来,低下头思索着如何才能让缓存系统更灵活、拥抱变化……

当然,这些都是在主管给出图1-2(1.3节)以及相应的代码之前,小民体验的煎熬过程。既然是这样,那显然主管给出的解决方案就能够让缓存系统更加灵活。一句话概括起来就是:依赖抽象,而不依赖具体实现。针对于图片缓存,主管建立的ImageCache抽象,该抽象中增加了get和put方法用以实现图片的存取。每种缓存实现都必须实现这个接口,并且实现自己的存取方法。当用户需要使用不同的缓存实现时,直接通过依赖注入即可,保证了系统的灵活性。我们再来简单回顾一下相关代码:

ImageCache缓存抽象:

1
2
3
4
public interface ImageCache {
public Bitmap get(String url);
public void put(String url, Bitmap bmp);
}

ImageLoader类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ImageLoader {
// 图片缓存类,依赖于抽象,并且有一个默认的实现
ImageCache mCache = new MemoryCache();
// 加载图片
public void displayImage(String url, ImageView imageView) {
Bitmap bmp = mCache.get(url);
if (bmp == null) {
// 异步加载图片
downloadImageAsync(url, imageView);
} else {
imageView.setImageBitmap(bmp);
}
}
/**
* 设置缓存策略,依赖于抽象
*/
public void setImageCache(ImageCache cache) {
mCache = cache;
}
// 代码省略
}

在这里,我们建立了ImageCache抽象,并且让ImageLoader依赖于抽象而不是具体细节。当需求发生变更时,小民只需要实现ImageCahce类或者继承其他已有的ImageCache子类完成相应的缓存功能,然后将具体的实现注入到ImageLoader即可实现缓存功能的替换,这就保证了缓存系统的高可扩展性,拥有了拥抱变化的能力,而这一切的基本指导原则就是我们的依赖倒置原则。从上述几节中我们发现,要想让我们的系统更为灵活,抽象似乎成了我们唯一的手段。

系统有更高的灵活性——接口隔离原则

接口隔离原则英文全称是InterfaceSegregation Principles,简称ISP。它的定义是:客户端不应该依赖它不需要的接口。另一种定义是:类间的依赖关系应该建立在最小的接口上。接口隔离原则将非常庞大、臃肿的接口拆分成为更小的和更具体的接口,这样客户将会只需要知道他们感兴趣的方法。接口隔离原则的目的是系统解开耦合,从而容易重构、更改和重新部署。

接口隔离原则说白了就是,让客户端依赖的接口尽可能地小,这样说可能还是有点抽象,我们还是以一个示例来说明一下。在此之前我们来说一个场景,在Java 6以及之前的JDK版本,有一个非常讨厌的问题,那就是在使用了OutputStream或者其他可关闭的对象之后,我们必须保证它们最终被关闭了,我们的SD卡缓存类中就有这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 将图片缓存到内存中
public void put(String url, Bitmap bmp) {
FileOutputStream fileOutputStream = null;
try {
fileOutputStream = new FileOutputStream(cacheDir + url);
bmp.compress(CompressFormat.PNG, 100, fileOutputStream);
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
if (fileOutputStream != null) {
try {
fileOutputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
} // end if
} // end if finally
}

我们看到的这段代码可读性非常差,各种try…catch嵌套,都是些简单的代码,但是会严重影响代码的可读性,并且多层级的大括号很容易将代码写到错误的层级中。大家应该对这类代码也非常反感,那我们看看如何解决这类问题。
我们可能知道Java中有一个Closeable接口,该接口标识了一个可关闭的对象,它只有一个close方法,如图1-4所示。
我们要讲的FileOutputStream类就实现了这个接口,我们从图1-4中可以看到,还有一百多个类实现了Closeable这个接口,这意味着,在关闭这一百多个类型的对象时,都需要写出像put方法中finally代码段那样的代码。这还了得!你能忍,反正小民是忍不了的!于是小民打算要发挥他的聪明才智解决这个问题,既然都是实现了Closeable接口,那只要我建一个方法统一来关闭这些对象不就可以了么?说干就干,于是小民写下来如下的工具类:
图1-4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public final class CloseUtils {
Private CloseUtils() { }
/**
* 关闭Closeable对象
* @param closeable
*/
public static void closeQuietly(Closeable closeable) {
if (null != closeable) {
try {
closeable.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

我们再看看把这段代码运用到上述的put方法中的效果如何:

1
2
3
4
5
6
7
8
9
10
11
public void put(String url, Bitmap bmp) {
FileOutputStream fileOutputStream = null;
try {
fileOutputStream = new FileOutputStream(cacheDir + url);
bmp.compress(CompressFormat.PNG, 100, fileOutputStream);
} catch (FileNotFoundException e) {
e.printStackTrace();
} final ly {
CloseUtils.closeQuietly(fileOutputStream);
}
}

代码简洁了很多!而且这个closeQuietly方法可以运用到各类可关闭的对象中,保证了代码的重用性。CloseUtils的closeQuietly方法的基本原理就是依赖于Closeable抽象而不是具体实现(这不是1.4节中的依赖倒置原则么),并且建立在最小化依赖原则的基础,它只需要知道这个对象是可关闭,其他的一概不关心,也就是这里的接口隔离原则。

试想一下,如果在只是需要关闭一个对象时,它却暴露出了其他的接口函数,比如OutputStream的write方法,这就使得更多的细节暴露在客户端代码面前,不仅没有很好地隐藏实现,还增加了接口的使用难度。而通过Closeable接口将可关闭的对象抽象起来,这样只需要客户端依赖于Closeable就可以对客户端隐藏其他的接口信息,客户端代码只需要知道这个对象可关闭(只可调用close方法)即可。小民ImageLoader中的ImageCache就是接口隔离原则的运用,ImageLoader只需要知道该缓存对象有存、取缓存图片的接口即可,其他的一概不管,这就使得缓存功能的具体实现对ImageLoader具体的隐藏。这就是用最小化接口隔离了实现类的细节,也促使我们将庞大的接口拆分到更细粒度的接口当中,这使得我们的系统具有更低的耦合性,更高的灵活性。

Bob大叔(Robert C Martin)在21世纪早期将单一职责、开闭原则、里氏替换、接口隔离以及依赖倒置(也称为依赖反转)5个原则定义为SOLID原则,指代了面向对象编程的5个基本原则。当这些原则被一起应用时,它们使得一个软件系统更清晰、简单、最大程度地拥抱变化。SOLID被典型地应用在测试驱动开发上,并且是敏捷开发以及自适应软件开发基本原则的重要组成部分。在经过第1.1~1.5节的学习之后,我们发现这几大原则最终就可以化为这几个关键词:抽象、单一职责、最小化。那么在实际开发过程中如何权衡、实践这些原则,是大家需要在实践中多思考与领悟,正所谓”学而不思则罔,思而不学则殆”,只有不断地学习、实践、思考,才能够在积累的过程有一个质的飞越。

更好的可扩展性——迪米特原则

迪米特原则英文全称为Law of Demeter,简称LOD,也称为最少知识原则(Least Knowledge Principle)。虽然名字不同,但描述的是同一个原则:一个对象应该对其他对象有最少的了解。通俗地讲,一个类应该对自己需要耦合或调用的类知道得最少,类的内部如何实现、如何复杂都与调用者或者依赖者没关系,调用者或者依赖者只需要知道他需要的方法即可,其他的我一概不关心。类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。

迪米特法则还有一个英文解释是:Only talk to your immedate friends,翻译过来就是:只与直接的朋友通信。什么叫做直接的朋友呢?每个对象都必然会与其他对象有耦合关系,两个对象之间的耦合就成为朋友关系,这种关系的类型有很多,例如组合、聚合、依赖等。

光说不练很抽象呐,下面我们就以租房为例来讲讲迪米特原则。
“北漂”的同学比较了解,在北京租房绝大多数都是通过中介找房。我们设定的情境为:我只要求房间的面积和租金,其他的一概不管,中介将符合我要求的房子提供给我就可以。下面我们看看这个示例:

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 class Room {
public float area;
public float price;
public Room(float area, float price) {
this.area = area;
this.price = price;
}
@Override
public String toString() {
return "Room [area=" + area + ", price=" + price + "]";
}
}
/**
* 中介
*/
public class Mediator {
List<Room> mRooms = new ArrayList<Room>();
public Mediator() {
for (inti = 0; i < 5; i++) {
mRooms.add(new Room(14 + i, (14 + i) * 150));
}
}
public List<Room>getAllRooms() {
return mRooms;
}
}
/**
* 租户
*/
public class Tenant {
public float roomArea;
public float roomPrice;
public static final float diffPrice = 100.0001f;
public static final float diffArea = 0.00001f;
public void rentRoom(Mediator mediator) {
List<Room>rooms = mediator.getAllRooms();
for (Room room : rooms) {
if (isSuitable(room)) {
System.out.println("租到房间啦! " + room);
break;
}
}
}
private boolean isSuitable(Room room) {
return Math.abs(room.price - roomPrice) < diffPrice
&&Math.abs(room.area - roomArea) < diffArea;
}
}

从上面的代码中可以看到,Tenant不仅依赖了Mediator类,还需要频繁地与Room类打交道。租户类的要求只是通过中介找到一间适合自己的房间罢了,如果把这些检测条件都放在Tenant类中,那么中介类的功能就被弱化,而且导致Tenant与Room的耦合较高,因为Tenant必须知道许多关于Room的细节。当Room变化时Tenant也必须跟着变化。Tenant又与Mediator耦合,就导致了纠缠不清的关系。这个时候就需要我们分清谁才是我们真正的“朋友”,在我们所设定的情况下,显然是Mediator(虽然现实生活中不是这样的)。上述代码的结构如图1-5所示。
图1-5
既然是耦合太严重,那我们就只能解耦了,首先要明确地是,我们只和我们的朋友通信,这里就是指Mediator对象。必须将Room相关的操作从Tenant中移除,而这些操作案例应该属于Mediator,我们进行如下重构:

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
/**
* 中介
*/
public class Mediator {
List<Room> mRooms = new ArrayList<Room>();
public Mediator() {
for (inti = 0; i < 5; i++) {
mRooms.add(new Room(14 + i, (14 + i) * 150));
}
}
public Room rentOut(float area, float price) {
for (Room room : mRooms) {
if (isSuitable(area, price, room)) {
return room;
}
}
return null;
}
private boolean isSuitable(float area, float price, Room room) {
return Math.abs(room.price - price) < Tenant.diffPrice
&& Math.abs(room.area - area) < Tenant.diffPrice;
}
}
/**
* 租户
*/
public class Tenant {
public float roomArea;
public float roomPrice;
public static final float diffPrice = 100.0001f;
public static final float diffArea = 0.00001f;
public void rentRoom(Mediator mediator) {
System.out.println("租到房啦 " + mediator.rentOut(roomArea, roomPrice));
}
}

重构后的结构图如图1-6所示:
图1-6
只是将对于Room的判定操作移到了Mediator类中,这本应该是Mediator的职责,他们根据租户设定的条件查找符合要求的房子,并且将结果交给租户就可以了。租户并不需要知道太多关于Room的细节,比如与房东签合同、房东的房产证是不是真的、房内的设施坏了之后我要找谁维修等,当我们通过我们的“朋友”中介租了房之后,所有的事情我们都通过与中介沟通就好了,房东、维修师傅等这些角色并不是我们直接的“朋友”。“只与直接的朋友通信”这简单的几个字就能够将我们从乱七八糟的关系网中抽离出来,使我们的耦合度更低、稳定性更好。
通过上述示例以及小民的后续思考,迪米特原则这把利剑在小民的手中已经舞得风生水起。就拿sd卡缓存来说吧,ImageCache就是用户的直接朋友,而SD卡缓存内部却是使用了jake wharton的DiskLruCache实现,这个DiskLruCache就不属于用户的直接朋友了,因此,用户完全不需要知道它的存在,用户只需要与ImageCache对象打交道即可。例如将图片存到SD卡中的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void put(String url, Bitmap value) {
DiskLruCache.Editor editor = null;
try {
// 如果没有找到对应的缓存,则准备从网络上请求数据,并写入缓存
editor = mDiskLruCache.edit(url);
if (editor != null) {
OutputStream outputStream = editor.newOutputStream(0);
if (writeBitmapToDisk(value, outputStream)) {
// 写入disk缓存
editor.commit();
} else {
editor.abort();
}
CloseUtils.closeQuietly(outputStream);
}
} catch (IOException e) {
e.printStackTrace();
}
}

用户在使用SD卡缓存时,根本不知晓DiskLruCache的实现,这就很好地对用户隐藏了具体实现。当小民已经“牛”到可以自己完成SD卡的rul实现时,他就可以随心所欲的替换掉jake wharton的DiskLruCache。小民的代码大体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
public void put(String url, Bitmap bmp) {
// 将Bitmap写入文件中
FileOutputStream fos = null;
try {
// 构建图片的存储路径 ( 省略了对url取md5)
fos = new FileOutputStream("sdcard/cache/" + imageUrl2MD5(url));
bmp.compress(CompressFormat.JPEG, 100, fos);
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
if ( fos != null ) {
try {
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
} // end if finally
}

SD卡缓存的具体实现虽然被替换了,但用户根本不会感知到。因为用户根本不知道DiskLruCache的存在,他们没有与DiskLruCache进行通信,他们只认识直接“朋友”ImageCache,ImageCache将一切细节隐藏在了直接“朋友”的外衣之下,使得系统具有更低的耦合性和更好的可扩展性。

总结

在应用开发过程中,最难的不是完成应用的开发工作,而是在后续的升级、维护过程中让应用系统能够拥抱变化。拥抱变化也就意味着在满足需求且不破坏系统稳定性的前提下保持高可扩展性、高内聚、低耦合,在经历了各版本的变更之后依然保持清晰、灵活、稳定的系统架构。当然,这是一个比较理想的情况,但我们必须要朝着这个方向去努力,那么遵循面向对象六大原则就是我们走向灵活软件之路所迈出的第一步。

参考文档:

  • 开发技术前线:面向对象六大原则
  • Android源码设计模式解析与实战

Intellij IDEA部署本地Tomcat

发表于 2015-11-11

概述

一直以来用Eclipse都是用本地tomcat、IDEA都是tomcat/jetty的maven插件的方式来跑Web工程。之前使用让IDEA用本地tomcat的方式来运行却没成功,一直困惑不已。然而今天公司需要这样做,问了下同事终于把这问题解决了。所以记录下!

部署步骤

安装本地Tomcat

Tomcat和JDK的安装可以查看这里win7下安装配置tomcat,java运行环境,具体的就不啰嗦了,这个步骤应该都操作过很多次了。但这是基础,jdk和tomcat没配好,接下来的所有工作都无法进行。

安装Tomcat and TomEE Integration

因为IDEA要依赖”Tomcat and TomEE Integration“这个插件来运行tomcat,所以我们的IDEA必须要进行安装。ULTIMATE版本的已经自带了,社区版的好像要自己装 。可以在工具栏的Run->Edit Configurations,在弹出来的界面中点击+,输入tomcat看是否有Tomcat Server,有的话就是已经安装了,没有的话就去Settings里的插件中心安装即可。

验证Project的Artifacts

打开Project的Module Setting(F4),看Artifacts里是不是有<project_name>:war和<project_name>:war exploded,如果你的是Web工程,一般都会有。如果没有就检查下是否是Web工程。

添加服务器配置

依次打开Run -> Edit Configurations -> + -> Tomcat Server -> Local,输入如下图中的配置。

配置热部署

在服务器的配置过程中,热部署是一个很常用的功能。如果没有配置热部署,那么你每修改一个java文件,甚至一个jsp文件,都必须手动restart server,那将是多么痛苦的一件事。

上面的图中,On ‘Update’ action是你点击Update按钮后的动作。而On frame deactivation是热部署的关键,为了实现热部署我们应该把它设置为Update classed and resources。 但是有时候我们发觉只有Do nothing、Update resources这两个选项,并没有Update classed and resources这个选项。其实这是因为我们的war包是选了 <project_name>:war这个,只要选回<project_name>:war exploded这个就可以了。

总结

其实本地tomcat的运行关键是依靠war包,而这个war包IDEA默认已经帮我们配置好了。所以把这个war包添加到tomcat的配置中就可以run on local了。


参考文档

  • CSDN:idea 修改jsp页面需要重新部署项目的额问题
  • win7下安装配置tomcat,java运行环境

链表-常见面试问题总结

发表于 2015-10-25

最近这段时间都在忙活校招,在面试的过程中遇到了很多有关链表的问题,由于以前没怎么搞过ACM,数据结构也忘的七七八八了,所以总体感觉回答得不是很好。昨天把链表的基础回顾了下,见这里单链表的基础操作,于是今天把面试过程中经常遇到的链表相关的问题总结下。

翻转

单链表的反转是面试的常用考点,所以必须掌握。思路是把当前节点拿过来作为已经翻转的表头,成为一个已翻转的子链,用result指向其头部(也就是当前节点)。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 反转单链表
public static Node traversingReverse(Node head) {
Node result = null;
Node temp = null;
// 头反转法:把当前节点拿过来作为已经翻转结果的表头
while (head != null) {
temp = head.next; // 保存下一个节点
head.next = result; // 当前节点放在结果的开头
result = head; // 当前节点的头
head = temp; // head指向下一个节点
}
return result;
}

懒删除(在O(1)时间删除链表节点)

有一种情况,当我们的now引用指向某一个节点的时候,我们需要删除该节点。然而如果不知道now节点的前驱节点,一般方法是无法删除now这个节点的。

这个时候,我们可以使用“懒”删除,这种方法的思想是:把now节点后驱节点的值赋给now节点,然后now节点的next指向的它后驱节点的下一个节点。因为不用遍历,所以这个算法的时间复杂度是O(1)。然而要注意的是尾节点,当now节点就是尾节点的时候,这种办法就行不通了。

代码如下:

1
2
3
4
5
6
7
8
9
// O(1)复杂度删除某个节点
public static void lazyDelete(Node now) {
if(now.next != null) { // ①非尾节点
now.value = now.next.value; // 复制后驱节点的值
now.next = now.next.next; // “删除”后驱节点
} else{ // ②尾节点,行不通
return;
}
}

求链表倒数第k个节点

经常会遇到求单链表倒数第几个节点的问题,如果按常规方法的话,我们需要先遍历整个链表,记录下节点个数,假设为n,然后再遍历一遍,第n-k+1个就是所求节点了。可是这个方法效率太低,需要两次遍历,有没有更加高效的方法呢?答案是肯定的。我们可以用 快慢指针 来解决这个问题,这个方法的思想是这样的:

  • 假设有引用fast、slow,开始都指向首节点;
  • fast先走k次,这样fast和slow就相隔了k个节点;
  • 然后fast、slow一起走,当fast走到链表末尾为null的时候,slow就是所求节点了。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 求单链表倒数第k个节点
public static Node getKNode(Node head, int k) {
Node fast = head;
Node slow = head;
int i = 0;
for(; i < k && fast != null; i++) {
fast = fast.next;
}
if(i != k) {
System.out.println(k + "位置超出链表长度");
}
while(fast != null) {
fast = fast.next;
slow = slow.next;
}
return slow;
}

求链表的中间节点

如果要求一个链表的中间节点,常规的方法是先遍历一次获取链表的长度n,如果n是偶数就n/2、n/2+1都可以,如果n是奇数,那就第n/2+1个。可是这个方法的效率也是非常低。

其实通过上面找用快慢指针的方法来找倒数第k个数,我们可以衍生到这里来。这个方法的思路是这样的:

  • 假设有引用fast、slow,开始都指向首节点;
  • fast、slow同时走,但是fast每次走2步,slow每次走1步;
  • 当fast走到链表末尾时,slow就是中间节点;

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 求链表的中间节点
public static Node getMiddleNode(Node head) {
if (head == null) {
return null;
}
Node fast = head;
Node slow = head;
// ①如果fast==null的话,说明是偶数个;而fast.next==null的话,说明是奇数个
// ①条件返回的是偶数情况中的后者,如果要返回前者,可用下面判断条件:
// while (fast != null && fast.next != null && fast.next.next != null)
while (fast != null && fast.next != null) { // ①
fast = fast.next.next; // 快指针走2步
slow = slow.next; // 慢指针走1步
}
return slow;
}

单链表是否存在环(leetcode 141)

其实判断单链表是否有环有2种方法,第一种是用Set集合的方法,第二种是用快慢指针的方法。

利用HashSet集合

这个方法的思想很简单,就是遍历该链表,每走一次,就判断HashSet中是否存在该节点,如果存在则说明有环,结束;如果不存在,说明还没有环,把该节点放到HashSet中去,直到链表的末尾。如果利用的是HashSet,则该算法的时间复杂度也是O(n),就是空间复杂度大了点。

代码如下:

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
// 利用HashMap判断是否有环
public static boolean hasCircle(Node head) {
Set<Node> set = new HashSet<>();
while (head != null) {
if(getFromSet(set, head) != null) {
return true;
}
set.add(head);
head = head.next;
}
return false;
}
// 由于Set没有提供get()方法,所以自己实现一个
// 缘何Set不提供get()???
public static Node getFromSet(Set<Node> set, Node node) {
Iterator it = set.iterator();
Node tmp;
while(it.hasNext()) {
tmp = (Node)it.next();
if(tmp == node) {
return tmp;
}
}
return null;
}

利用快慢指针

上面的方法实现起来很简单高效,但是却需要额外的空间,空间复杂度是O(n),而快慢指针是一个更高效的方法。让快指针每次走2步,慢指针每次走1步,两个指针的速度不一样,如果存在环的话,那么最后快慢指针肯定会相遇。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 利用快慢指针判断链表是否有环
public static boolean hasCircle(Node head) {
Node fast = head;
Node slow = head;
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
if (fast == slow) {
return true;
}
}
return false;
}

单链表中环的起点(leetcode 142)

快慢指针求环起点
假设链表中存在环,我们先设定几个参数:n(环的长度=7)、a(链表起点到环起点的距离=3)、x(fast指针到环起点的时候,slow指针在环中的位置0 <= x < n),由此我们可以推出下面几个性质:

  1. slow到起点(s3)的时候,fast在环中走了x步(f3),那么fast和slow相差 n - x 步,也就是说 n - x 步后,fast会追上slow;
  2. 经过 n - x 步后(4步),fast和slow在环中M点相遇(f7与s7);
  3. 假设这时候相遇点M与环起点的距离为b(=4),则slow走过的距离是 a + b ,而fast走过的距离为 a + b + k n 。显然fast走的长度是slow的2倍,所以2 ( a + b ) = a + b + k n,则 **a + b = k n**;
  4. 把fast指针拉回链表的起始点,这时候fast和slow每次都走一步,经过a步后,fast和slow在环的起始点相遇。

代码如下:

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
// 使用快慢指针求环起点
public static Node listCircleStart(Node head) {
// 空链或者单节点,不存在环
if(head == null || head.next == null) {
return null;
}
Node fast = head;
Node slow = head;
do {
if (fast == null || fast.next == null) { // 无环
return null;
}
fast = fast.next.next; // 走2步
slow = slow.next; // 走1步
} while (fast != slow) // 直到fast和slow相遇
fast = head; // 重新拉回链表起点
while (fast != slow) { // 相遇即为环起点
fast = fast.next; // 走1步
slow = slow.next; // 走1步
}
return fast;
}

单链表中环的长度

在上面,我们找到环的起点后,再用slow指针走一遍,就可以算出环的长度了。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 使用快慢指针求环长度
public static int listCircleLength(Node head) {
Node cicleStart = listCircleStart(head); // 先利用上面的方法求出环起点
if(cicleStart == null) {
return 0;
}
Node tmp = cicleStart.next;
int counter = 1;
while (tmp != cicleStart) { // 重走一遍,直到回到环起点
counter++;
tmp = tmp.next;
}
return counter;
}

两个链表是否相交并求出交点(leetcode 160)

给定2个链表,判断这两个链表是否相交,如果相交的话,就类似于一个向左翻转90°的Y。见下图:
相交链表

这个问题相比环来说简单很多,我们可以用Set集合方法、链长先走方法、成环方法来解决。下面我们逐一来看。

Set集合方法

这个方法的思路最简单:

  1. 先遍历a链,依次把所有节点放入到Set中去;
  2. 然后遍历b链,每到一个节点,就去Set看是否存在该节点,如果存在,则说明有交点,而交点就是该节点(第一个相等的肯定是交点);
  3. 如果直到b链的末尾都没有相等节点,则说明没有交点;

这个方法其实比较笨,效率也比较低。

代码如下:

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
// 利用Set求2链表交点
public static Node getCrossNode(Node headA, Node headB) {
Set<Node> set = new Hash<>();
// 把a链全部放入Set中
while (headA != null) {
set.add(headA);
headA = headA.next;
}
// 遍历b链
while (headB != null) {
if(set.getFromSet(set, headB) != null) { // 相交
return headB;
}
}
return null;
}
public static Node getFromSet(Set<Node> set, Node node) {
Iterator it = set.iterator();
Node tmp;
while(it.hasNext()) {
tmp = (Node)it.next();
if(tmp == node) {
return tmp;
}
}
return null;
}

链长先走方法

这个方法的思路是这样的:

  1. 遍历一次,求出链a的长度x,链b的长度y,求出|x-y|=k,这个k是链a和链b的长度差;
  2. 让长的链先走k步,这样a、b链剩下的长度都是一样的;
  3. 然后a、b一起走,遇到相等的节点,就是它们的交点;如果没相遇,说明没有交点;

这个方法比Set的方法好很多,因为不用用到Set集合,所以快很多。

代码如下:

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
// 链长先走求2链交点
public static Node getCrossNode(Node headA, Node headB) {
int x = 0; // 链a长度
int y = 0; // 链b长度
Node tmp = headA; // 因为后续还要用到headA,所以用临时变量来遍历
while(tmp != null) {
x++;
tmp = tmp.next;
}
tmp = headB;
while(tmp != null) {
y++;
tmp = tmp.next;
}
int k; // 链长度差
if (x - y > 0) { // a链长
k = x - y;
// a链走k步
for (int i = 0; i < k; i++) {
headA = headA.next;
}
} else {
k = y - x;
// b链走k步
for (int i = 0; i < k; i++) {
headB = headB.next;
}
}
// 这时候,headA指向的子链和headB指向的子链长度一样,一起走
while (headA != null) { // 这里不需要判断headB,因为headA和headB肯定同时为null
if(headA == headB) { // 相遇的节点是交点
return headA
}
headA = headA.next;
headB = headB.next;
}
return null;
}

成环方法

我们把a链的首尾连接起来,这样a链就形成了一个环。如果a、b链相交,那么从b链来看,链b这个时候就会出现环路,我们可以用上面找环的方法来解决。如下图:
成环方法求两链交点

这个方法的思路如下:

  1. 把a链的尾部指向a链头部,让其形成一个环;
  2. 从b链头部开始遍历,如果b中能找到环,说明ab相交,环的起点即为交点;如果不能找到环,说明无交点;
  3. 把a链中的环断开,恢复链a;

这个方法的代码和找环的代码差不多,这里就不写了。

复制带有随机指针的链表(leetcode 138)

一个单链表除了next指针外,还有一个random指针,random指针随机指向任何一个元素(可能为null),然后我们的任务是复制它。

这个复制其实next指针直接用常规方法就能解决,难点在于random指针,因为我们不知道random指针在复制后的地址-复制元素的地址变了,而且random指向的元素可能还没生成。

要解决这个问题,我们有2种方法:map集合方法、“副本”方法。

map集合方法

这个方法的思路是这样的:

  1. 先遍历一次原链,用常规方法复制一个新链,使其next指针指向正确的位置,random指针为null;而且在这个过程中,每生成一个新节点,用map来保存旧节点到新节点的映射,类似与map(oldNode, newNode);
  2. 再同时遍历原链和新链,在原链中得到节点的random指向的oldNode,然后利用map.get(oldNode)获取newCode,把新链节点的random指向这个newNode;

结合下图:
利用map复制带random的链表

容易知道,这个方法的时间复杂度是O(n),空间复杂度也是O(n),效率比较低。

代码如下:

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
// 利用map实现带random指针的链表复制
public static NodeWithRandom copyWithRandom (NodeWithRandom h) {
NodeWithRandom newNode = null;
Map<NodeWithRandom, NodeWithRandom> map = new HashMap<>();
map.put(null, null); // 解决random指向null的情况
// 先用常规方法复制链表
NodeWithRandom tmp1 = h;
NodeWithRandom h2 = null;
while (tmp1 != null) {
if(newNode == null) { // 表头
newNode = new NodeWithRandom(tmp1.value);
h2 = newNode;
} else {
newNode.next = new NodeWithRandom(tmp1.value);
newNode = newNode.next;
}
map.put(tmp1, newNode); // 旧地址到新地址的映射
tmp1 = tmp1.next;
}
// 再两个链表同时走复制random,a'.random = map[a.random]
tmp1 = h;
NodeWithRandom tmp2 = h2;
while (tmp1 != null) {
tmp2.random = map.get(tmp1.random); // 利用map的映射,将新链表的random指针指向新节点
tmp1 = tmp1.next;
tmp2 = tmp2.next;
}
return h2;
}
// 带random的节点
class NodeWithRandom {
public int value;
public NodeWithRandom next;
public NodeWithRandom random;
public NodeWithRandom (int value) {
this.value = value;
this.next = null;
this.random = null;
}
}

“副本”方法

这个方法的思路主要是:

  1. new副本:在每个旧节点后插入一个当前节点的副本,重新拉成链;
  2. 复制random:新节点的random=旧节点random的next;
  3. 拆分:奇数项都是旧节点,偶数项都是新节点,提取偶数项成链;

如下图:
“副本”方法复制带随机指针的链表

代码如下:

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
// "副本"方式复制带随机指针链表
public static NodeWithRandom copyRandomList (NodeWithRandom head) {
if (head == null) {
return null;
}
// 在每个节点后创建副本,拉成链
NodeWithRandom now = head;
while (now != null) {
NodeWithRandom copy = new NodeWithRandom(now.value);
copy.next = now.next;
now.next = copy;
now = copy.next;
}
// 复制random
for (now = head; now != null; now = now.next.next) {
now.next.random = now.random == null ? null : now.random.next; // 新节点的random=旧节点random的next
}
// 奇数项为旧节点,偶数项为新节点
// 提取出偶数项就是所求
NodeWithRandom h = head.next; // 新链头部
NodeWithRandom t = h; // 遍历游标
NodeWithRandom tail = head;
for (;;) {
tail.next = t.next; // 重连旧节点
tail = tail.next; // 移向下一个旧节点
if(tail == null) {
break;
}
t.next = tail.next; // 连接新节点
t = t.next; // 移向下一个新节点
}
return h;
}

链表partition过程(leetcode 86)

链表里存放整数,给定x,把x小的节点放在>=x之前,这就是一个partition过程,数组在partition的过程就是这样做的。然而链表和数组不同的是,在patition的过程中,链表没必要赋值来赋值去,而是重新起一个头,把比x小的连起来,把>=x的连成另外一条链,最后再把这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
// 链表的partition过程
public static Node(Node head, int x) {
Node h1 = null; // 小子链头部
Node h2 = null; // 大子链头部
Node t1 = null; // 小子链尾部
Node t2 = null; // 大子链尾部
for (; head != null; head = head.next) {
if (head.val < x) { // 比x小
if (h1 = null) { // 头部
h1 = head;
t1 = head;
} else {
t1.next = head;
t1 = t1.next;
}
} else {
if (h2 == null) {
h2 = head;
t2 = head;
} else {
t2.next = head;
t2 = t2.next;
}
}
}
if (t2 != null) { // 大子链有节点
t2.next = null;
}
if(t1 != null) { // 小子链有节点
t1.next = h2;
}
return h1 != null ? h1 : h2;
}

总结

从上面可以看出,涉及链表的问题,大部分都可以用快慢指针或集合的方式来解决,所以遇到这种问题可以往这方面考虑。由于链表实在非常灵活,所以具体的问题还是得灵活处理。


参考文档

  • 面试精选:链表问题集锦
  • 七月算法:链表面试精讲

单链表的基础操作

发表于 2015-10-14

概述

我们知道,线性表分为顺序表和链式表。顺序表是基于数组的存储表示,其特点是用物理位置上的邻接关系来表示结点间的逻辑关系。但是基于数组的顺序表的缺点也非常明显,比如说插入/删除的效率低下(平均需要移动一半元素)、需要预先进行存储分配等。

为了克服顺序表的缺点,可以采用链接方式来存储线性表,通常将链接方式的线性表称为链表。链表适用于插入/删除频繁,存储空间不确定的场景。单链表的特点是长度可以很方便地进行扩充,其数据元素的顺序与其链表表示中节点的物理顺序可能不一致,一般通过单链表的指针将各个数据元素按照线性表的逻辑顺序连接起来,如下图:
单链表示意图

由于链表的每个节点带有指针域(引用),因而在存储空间上比顺序表要付出较大的代价。

创建

创建单链表主要有前插和后插两种方法,建立的方式不同,最终得到的链表也不相同。

前插法建立单链表

前插法是指每次插入新节点总是在表的前端进行,这样插入的结果是链表中各节点中数据的逻辑顺序与输入顺序是正好相反的。其主要步骤如下:

  1. 生成新节点newNode,将读入数据存放到newNode的data域中;
  2. 将newNode插入到链表的前端;

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 建立链表
private static Node buildList(int size) {
Node first = null;
Node newNode = null;
// 头插入法
for (int i = 0; i < size; i++) {
newNode = new Node(i);
newNode.next = first;
first = newNode;
}
return first;
}

后插法建立单链表

后插法是指每次newNode总是插入到链表的尾端,这样插入的结果,链表中各个节点的逻辑顺序和输入数据的顺序是完全一致的。这个方法需要设置一个尾指针last,总是指向新链表中最后一个节点,新节点连接到它所指链尾的后面。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private static Node buildListLast(int size) {
Node first = null;
Node last = null;
Node now = null;
// 后插法
for(int i = 0; i < size; i++) {
now = new Node(i);
now.next = null;
if(first == null) {
first = now;
} else {
last.next = now;
}
last = now;
}
return first;
}

插入

利用单链表来表示线性表,将使得插入和删除变得非常方便,只要修改链中节点指针或引用的值,无需移动表中的元素,就能高效地实现插入和删除操作。

对于插入,我们希望在单链表(a1,a2,a3,…an)的包含数据ai的节点之后插入一个newNode,那么可能会出现3种情况:

  1. 插入到链表头部。这个时候newNode应插入在第一个节点之前,如下图a所示。这时必须修改头指针first。插入操作为:

    1
    2
    newNode.next = first;
    first = newNode;
  2. 插入到链表中间。此时,首先让一个检测指针current指向ai所在节点,再将新节点插入到ai之后,如下图b所示。插入操作为:

    1
    2
    newNode.next = current.next;
    current.next = newNode;
  3. 插入到链表尾端。新节点追加到表尾,如下图c所示。这时,应先令检测指针current指向最后一个节点an,再执行:

    1
    2
    newNode.next = current.next;
    current.next = newNode;

链表插入的示例

从上面可以看出,2和3情形的操作一致,所以这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
// 将新元素插入到第index个节点之后。index从1开始,index = 0表示插入到第一个节点之前
private static Node insert(Node first, Node newNode, int index) {
// ①如果链表为空,或者插入的索引为0,则插入到前端
if(first == null || index == 0) {
newNode.next = first;
first = newNode;
} else {
Node current = first;
for(int k = 1; k < index; k++) {
if(current == null) {
break;
} else {
current = current.next;
}
}
if(current == null) {
System.out.println("无效的插入位置");
} else { // ②插入中间或尾部
newNode.next = current.next;
current.next = newNode;
}
}
return first;
}

删除

单链表的删除算法比较简单,但也分为2种情况:

  1. 删除头部。这时需要用一个del引用先保存头部节点,再将表头引用指向下一个节点,使其成为新链表的头部,最后删除del保存的节点。如下图a所示。删除操作为:

    1
    2
    3
    Node del = first;
    first = first.next;
    del = null;
  2. 删除中间节点或尾部。设删除链表中的第i个节点,首先用引用del保存第i个节点,再让第i-1个节点的next引用指向第i+1个节点,通过重新拉链,把第i个节点从链表中分离出来,最后删除del引用的节点。如下图b所示。删除操作为:

    1
    2
    3
    Node del = current.next;
    current.next = del.next;
    del = null;

在单链表中删除ai节点

由此可得单链表的删除算法:

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
// 将链表的第index个元素删除,index从1开始
private static Node delete(Node first, int index) {
Node del = null;
Node current = null;
if(index < 1) { // ①删除头结点
del = first;
first = first.next;
del = null; // 让GC回收
} else {
current = first;
for(int k = 1; k < index - 1; k++) {
if(current == null) {
break;
} else {
current = current.next;
}
}
if(current == null || current.next == null) {
System.out.println("无效的删除位置");
} else { // ②删除中间节点或尾部节点
del = current.next;
current.next = del.next;
del = null;
}
}
return first;
}

遍历

遍历应该是最简单的了,直接看代码:

1
2
3
4
5
6
7
8
9
// 遍历链表
private static void traveling(Node first) {
Node h = first;
while (h != null) {
System.out.print(h.value + " ");
h = h.next;
}
}

总结

本文主要介绍了链表的相关基础操作:创建、插入、删除、遍历。链表本身很灵活,所以很考察编程功底,所以掌握基础至关重要,接下来还会再写一篇,记录下其他链表的其他进一步的运用。


参考文档

  • 数据结构(用面向对象方法与C++语言描述) 第二版 殷人昆主编 清华大学出版社
  • 七月算法:链表面试精讲

设计模式系列一之单例模式(Singleton)

发表于 2015-10-13

概述

学习设计模式的时候,经常会以单例模式作为第一个案例来分析,因为这个模式比较简单,容易理解。但其实一旦牵涉到并发的时候,单例模式也往往很多问题。所以需要系统的梳理一下。

单例模式是一种创建型模式,在Java应用中,单例对象必须保证在一个Java虚拟机中,该对象有且只能有一个实例。这个模式有一下几种应用场景(或者说有这么些优点):

  • 某些类的实例对象创建,开销比较大,消耗很多的系统资源;
  • 对于某些频繁使用的对象,减少new操作,避免在Java堆中产生过多实例,降低GC压力;
  • 在某些场景中,多个实例对象会导致系统错乱。类似于一个Company有多个CEO,听谁的。

以上这几种情况就非常适合使用单例模式来解决。

单例模式有一下几个特点:

  • 必须保持只有一个实例;
  • 必须自己创建一个自己的实例(因为构造方式是private的);
  • 必须为其他对象提供唯一的实例(需要提供一个getInstance()方法);

单例模式主要有以下5种方式,逐一分析下。

饿汉式(线程安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
class Singleton {
// 类加载的时候就会初始化,这个过程有且仅有一次
private static final Singleton instance = new Singleton();
// 私有的构造方法
private Singleton() {}
// 对外提供唯一的Singleton实例
public static Singleton getInstance() throws InterruptedException {
return instance;
}
}

这种方式非常简单,因为单例的实例被声明为static和final,在JVM第一次加载该单例类到内存中的时候就会初始化【1】,所以创建实例本身是线程安全的。

然而这种方式的缺点也是显而易见的:

  • 这个方式不是懒加载模式(lazy initialization),单例会在第一次加载的时候就被初始化,即使客户端没有调用getInstance()方法。这个时候如果单例类的开销比较大或者时间花费较多,就会降低系统效率。
  • 在一些场景中是无法使用这种方式的:比如Singleton的创建是依赖参数或配置文件的,在getInstance之前必须调用某个方法设置参数给它,那么这种单例就无法使用了。

懒汉式(线程不安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() throws InterruptedException {
if (instance == null) { // a
// 为了加大出现线程问题的概率,我们在这里休眠1秒
Thread.sleep(1000);
instance = new Singleton(); // b
}
return instance;
}
}

上面这段代码简单明了,而且使用了懒加载模式,但是却存在着致命伤。当有多个线程同时运行这段代码的时候,就非常可能创建出多个实例,也就是说这个单例类在并发环境中是无法正确运行的。

我们来分析下出现错误的原因:假设有2个线程同时来到了a处,此时的instance为null。假设线程1先进入if语句块中,但是还没执行b,就交出了CPU使用权。接着线程2获取CPU使用权,进入if语句块,执行了b,生成一个对象,这时候交出了CPU使用权。这时候,线程1接着执行b,又生成了一个对象。而在这2个对象的创建期间,其他类可能调用了getInstance()方法,极大可能调用了第1个对象,然后第2个对象创建后,其他类又调用的是第2个对象。这就严重违反了单例模式的核心功能,所以这种写法是完全不正确的。

懒汉式(synchronized)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Singleton {
private static Singleton instance;
private Singleton() {}
public static synchronized Singleton getInstance() throws InterruptedException {
if (instance == null) { // a
// 为了出现线程问题,我们在这里休眠1000毫秒。(当然这里并不出现线程安全问题)
Thread.sleep(1000);
instance = new Singleton(); // b:这里不会释放掉对象锁,所以其他线程无法进入
}
return instance;
}
}

为了解决线程安全问题,我们需要使用Java提供的同步机制。为此我们用synchronized关键字来修饰getInstance()方法。

这种方式虽然做到了线程安全,但是却也并不高效。因为每次只能有一个线程调用getInstance()方法,但是明显同步操作只需要在第一次初始化的时候才被需要。为此我们引入了双重校验锁。

懒汉式(双重校验锁Double Checked Locking)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Singleton {
private static Singleton instance;// private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() throws InterruptedException {
if(instance == null){// 第一次校验
synchronized (Singleton.class){// 锁
if(instance == null){// 第二次校验
Thread.sleep(1000);
instance = new Singleton();// a:可加赋值的内存屏障
}
}
}
return instance;
}
}

其实这种方式本质上就是在同步方式的基础上再加一层校验(也就是if语句判断),只不过把同步的方法换成了同步块。

但是这段代码还是可能出现问题,问题出现在a处instance = new Singleton();这句代码并非是一个原子操作【2】,JVM大概做了3件事:

  1. 【分配引用内存】给instance分配内存;
  2. 【分配实例内存】调用Singleton的构造函数来初始化成员变量;
  3. 【引用指向实例地址】将instance引用指向分配的实例内存,这时候instance才不为null;

但是JVM可能存在指令重排序,也就是说上面的2、3顺序是不一定的,最终执行顺序可能是1-2-3,也可能是1-3-2,如果是后者,则在执行完3、2未执行的时候,被线程2夺取了CPU,那么这个时候instance不为null,但是却没有初始化,所以线程2直接就返回了instance,使用的时候就报错了。

为此我们需要把intance声明为volatile,有部分人认为volatile是保证了可见性,也就是保证线程在本地内存中不会存有instance的副本,每次都是去主内存中获取。但其实是不对的,即使是保证了可见性,也还是会出现上面的问题。使用volatile的主要原因是其另外一个特性:禁止指令重排序。在volatile变量的赋值操作后,会生成一个内存屏障(在汇编代码上),读操作不会被重排序到内存屏障前。比如上面的例子,取操作必须在执行完1-2-3或1-3-2后,不存在执行到1-3后读取instance的情况。从“先行发生原则”的角度来理解的话,就是对于一个volatile变量的写操作是先行发生与后面对这个变量的读操作(这里的后面指的是时间概念)。

但是特别注意的是,在JDK1.5之前,使用volatile的双重校验锁还是有问题的。因为JDK1.5之前的内存模型是存在缺陷的,即使将变量声明为volatile也不能完全避免重排序,主要是volatile变量前后的代码仍然存在重排序问题。在JDK1.5及之后这个问题得到了修复,可以放心使用。

静态内部类方式

1
2
3
4
5
6
7
8
9
10
11
12
13
class Singleton {
private static class SingletonHolder{
private static final Singleton INSTANCE = new Singleton();
}
private Singleton(){}
public static final Singleton getInstance(){
return SingletonHolder.INSTANCE;
}
}

静态内部类是最被提倡使用的方式,在《effective java》上也是被推荐的。这种写法仍然使用了JVM本身机制保证了线程安全问题。由于SingletonHolder是私有的,除了getInstance()没有其他方法能够访问它,因此它是懒汉式的。同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖 JDK 版本。这种方式其实是懒汉式和饿汉式的结合。

这种方式使用一个私有静态内部类来维护单例的实现,JVM内部机制保证当一个类被加载时,这个类的加载过程是线程互斥的。这样,当我们第一次调用getInstance()方法的时候,JVM能够帮助我们保证INSTANCE只被创建一次,并且会保证把赋值给INSTANCE的内存初始化完毕,这样我们就不用担心上面的问题了。同时该方法也只会在第一次使用的时候使用互斥机制,这样就解决了低性能的问题。

枚举法

在《Effective Java》最后提供了枚举法来实现单例模式,不仅简单,而且保证了线程安全。此外,这个方法还提供了防止序列化和反射创建多个实例的问题,即使面对复杂的序列化和反射攻击(不清楚序列化和反射攻击的,可以看这里 单例模式的破坏)。

单元素的枚举类型已经成为实现Singleton的最佳方法。《Effective Java》

1
2
3
public enum Singleton {
INSTANCE;
}

首先,在枚举类,构造方法自动声明为private,同时每个元素都自动声明为static final,表明元素只能被实例化一次。也就是说,enum里的实例都只会被实例化一次,所以我们INSTANCE也被保证只实例化一次。此外,我们看一下enum的定义:

1
2
public abstract class Enum<E extends Enum<E>>
implements Comparable<E>, Serializable

可以看出,Enum实现了序列化接口,即提供了序列化机制。

枚举是如何防止序列化生成多个实例的

我们知道,反序列化实例化对象是通过ObjectInputStream的readObject方法实现的,其中枚举的调用链是:

readObject -> readObject0 -> readEnum -> checkResolve

我们重点来看readEnum方法,部分实现如下:

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
private Enum<?> readEnum(boolean unshared) throws IOException {
...
String name = readString(false);
Enum<?> result = null;
Class<?> cl = desc.forClass();
if (cl != null) {
try {
@SuppressWarnings("unchecked")
Enum<?> en = Enum.valueOf((Class)cl, name);
result = en;
} catch (IllegalArgumentException ex) {
throw (IOException) new InvalidObjectException(
"enum constant " + name + " does not exist in " +
cl).initCause(ex);
}
if (!unshared) {
handles.setObject(enumHandle, result);
}
}
handles.finish(enumHandle);
passHandle = enumHandle;
return result;
}

看第10行代码Enum<?> en = Enum.valueOf((Class)cl, name);,可以看出是通过Enum.valeOf来获取枚举类对象的,那我们去看一下这个方法。

1
2
3
4
5
6
7
8
9
10
public static <T extends Enum<T>> T valueOf(Class<T> enumType,
String name) {
T result = enumType.enumConstantDirectory().get(name);
if (result != null)
return result;
if (name == null)
throw new NullPointerException("Name is null");
throw new IllegalArgumentException(
"No enum constant " + enumType.getCanonicalName() + "." + name);
}

实际上是通过调用enumType(Class对象的引用)的enumConstantDirectory方法获取到一个Map集合,在该集合内存储了以枚举类name为key,枚举实例变量值为value的键值对数据,因此通过name就可以获取到枚举实例。我们再来看下enumConstantDirectory方法的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
Map<String, T> enumConstantDirectory() {
if (enumConstantDirectory == null) {
T[] universe = getEnumConstantsShared();
if (universe == null)
throw new IllegalArgumentException(
getName() + " is not an enum type");
Map<String, T> m = new HashMap<>(2 * universe.length);
for (T constant : universe)
m.put(((Enum<?>)constant).name(), constant);
enumConstantDirectory = m;
}
return enumConstantDirectory;
}

getEnumConstantsShared方法最终利用反射调用枚举类的values方法来获取的枚举类实例数组,然后再以键值对的方式存储到Map集合中去。

到这里,我们的确可以看出来,枚举类反序列化的确不会重新创建实例,JVM保证了每个枚举类实例变量的唯一性。

枚举是如何防止反射生成多个实例的

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) throws Exception {
// 常规方法获取实例
Singleton commonSingleton = Singleton.getInstance();
// 通过反射获取实例
Constructor constructor = Singleton.class.getDeclaredConstructor(String.class, int.class);
constructor.setAccessible(true);
Singleton reflectionSingleton = (Singleton) constructor.newInstance();
// 这里会输出: false,证明两个实例不一样,单例模式被破坏了
System.out.println(reflectionSingleton == commonSingleton);
}

我们知道,反射破坏单例主要是通过利用调用私有构造器来实现的,当用反射尝试实例化枚举类的时候,会抛出下面的异常:

1
2
3
Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
at com.github.archerda.designpattern.singleton.destory.reflection.BreakingSingleton.main(BreakingSingleton.java:23)

显然,异常告诉我们,不能通过反射实例化枚举类,为什么呢?我们来看下newInstance的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@CallerSensitive
public T newInstance(Object ... initargs)
throws InstantiationException, IllegalAccessException,
IllegalArgumentException, InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, null, modifiers);
}
}
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
throw new IllegalArgumentException("Cannot reflectively create enum objects");
ConstructorAccessor ca = constructorAccessor; // read volatile
if (ca == null) {
ca = acquireConstructorAccessor();
}
@SuppressWarnings("unchecked")
T inst = (T) ca.newInstance(initargs);
return inst;
}

看第12行,很显然了。

总结

  • 常见的5种单例模式实现方式:

    • 主要:
      • 饿汉式:线程安全,调用效率高,不能延迟加载;
      • 懒汉式:线程安全,调用效率低,但是可以延迟加载;
    • 其他:
      • 双重检验锁:JDK1.5之前底层内部模型有问题,不建议使用;
      • 静态内部类式:线程安全,调用效率高,可以延迟加载;
      • 枚举式:线程安全,调用效率高,不能延迟加载,但是可以天然防止反射和序列化漏洞;
  • 如何选用:

    • 单例对象占用资源少,不需要延迟加载:枚举式 > 饿汉式;
    • 单例对象占用资源大,需要延迟加载:静态内部式 > 懒汉式

在JDK中的应用

java.lang.Runtime

Runtime类封装了Java运行时的环境。因为Java程序实际是启动了一个JVM进程,而每个JVM进程都对应一个Runtime实例,此实例是由JVM来实例化的。每个Java程序都有一个Runtime实例,使应用程序能够与其运行的环境交互。

由于Java进程是单进程的,所以在一个JVM中,Runtime实例应该只要一个。所以应该用单例模式来实现。

1
2
3
4
5
6
7
8
9
10
11
public class Runtime {
private static Runtime currentRuntime = new Runtime();
public static Runtime getRuntime() {
return currentRuntime;
}
private Runtime() {}
....
}

以上代码为JDK中Runtime代码的部分实现,明显,这是饿汉式的实现方式。在该类被ClassLoader第一次加载的时候被创建。

在Spring中的应用

Spring单例Bean与单例模式的区别在于它们关联的环境不一样,单例模式是指在一个JVM进程中仅有一个实例,而Spring单例是指一个SpringBean容器(ApplicationContext)中仅有一个实例,所以如果一个JVM进程中有多个IoC容器,即使是单例Bean,也会有多个实例,比如:

1
2
3
4
5
6
<!-- 即使声明了为单例,只要有多个容器,也一定会创建多个实例 -->
<bean id="person" class="com.github.archerda.Person" scope="singleton">
<constructor-arg name="username">
<value>archerda</value>
</constructor-arg>
</bean>

1
2
3
4
5
6
7
8
// 第一个Spring Bean容器
ApplicationContext context1 = new FileSystemXmlApplicationContext("classpath:/ApplicationContext.xml");
Person person1 = context1.getBean("person", Person.class);
// 第二个Spring Bean容器
ApplicationContext context2 = new FileSystemXmlApplicationContext("classpath:/ApplicationContext.xml");
Person person2 = context2.getBean("person", Person.class);
// 这里绝对不会相等,因为创建了多个实例
System.out.println(person1 == person2);

疑问

【1】如果是final的话,应该是静态绑定的,在编译期间就可以确定,参考使用父类常量不会触发定义常量的类的初始化。
【2】这与Java 内存模型中提到的synchroized保证了原子性有冲突?


参考文档

  • Jark’s Blog: 如何正确地写出单例模式
  • CSDN: Java之美[从菜鸟到高手演变]之设计模式
  • CSDN: 单例模式有什么好处
  • CSDN:深入理解Java枚举类型(enum)
  • CSDN:单例模式详解(解决反射反序列化问题)

Sublime Text 3 快捷键以及常用配置

发表于 2015-10-01

快捷键(Mac平台)

符号
⌘ command
⌃ control
⌥ option/alt
⇧ shift
↩ enter
⌫ delete
  • 打开Console:⌃ `
  • 打开命令模式:⌘ ⇧ p
  • 根据文件名打开文件:⌘ p
  • 定位函数方法:⌘ r
  • 多行游标:⌘ d(选中) ⌘ k(跳过)
  • 快速跳行:⌃ g
  • 删除整行:⌃ ⇧ k
  • 剪切整行:⌘ x
  • 本文件查找:⌘ f
  • 全局查找:⌘ ⇧ f
  • 替换:⌘ ⌥ f

插件

安装Package Control:

1
import urllib.request,os,hashlib; h = '2915d1851351e5ee549c20394736b442' + '8bc59f460fa1548d1514676163dafc88'; pf = 'Package Control.sublime-package'; ipp = sublime.installed_packages_path(); urllib.request.install_opener( urllib.request.build_opener( urllib.request.ProxyHandler()) ); by = urllib.request.urlopen( 'http://packagecontrol.io/' + pf.replace(' ', '%20')).read(); dh = hashlib.sha256(by).hexdigest(); print('Error validating download (got %s instead of %s), please try manual install' % (dh, h)) if dh != h else open(os.path.join( ipp, pf), 'wb' ).write(by)

Emmet:

提供了一种非常简练的语法规则,然后立刻生成对应的 HTML 结构或者 CSS 代码,同时还有多种实用的功能帮助进行前端开发。

  • 自动生成HTML5初始代码:html:5
  • 生成特定id节点:span#idName
  • 生成特定class节点:span.className

AutoFileName:

自动补全文件路径,非常方便。

DocBlockr:

DocBlockr会成为你编写代码文档的有效工具。当输入/**并且按下Tab键的时候,这个插件会自动解析任何一个函数并且为你准备好合适的模板。

Alignment:

代码对齐。

jQuery:

jQuery代码提示。

JsFormat:

JavaScript代码格式化。

Tag:

HTML/XML标签缩进、补全和校验。

HTML-CSS-JS Prettify:

代码美化。

ConvertToUTF8:

通过本插件,可以编辑并保存目前编码不被 Sublime Text 支持的文件,特别是中日韩用户使用的 GB2312,GBK,BIG5,EUC-KR,EUC-JP 等。

主题及配色

Material

个人配置

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
{
"auto_complete" : true,
"auto_complete_selector": "source - comment",
"auto_complete_triggers":
[
{
"characters": "."
},
{
"characters": ">"
},
{
"characters": ":"
}
],
"bold_folder_labels": true,
"color_scheme": "Packages/Material Theme/schemes/Material-Theme.tmTheme",
"fade_fold_buttons": false,
"font_face": "Monaco",
"font_size": 13,
"highlight_line": true,
"highlight_modified_tabs": true,
"ignored_packages":
[
"Vintage"
],
"line_padding_bottom": 1,
"line_padding_top": 1,
"open_files_in_new_window": false,
"theme": "Material-Theme.sublime-theme",
"material_theme_accent_purple": true,
"material_theme_small_statusbar": true,
"material_theme_small_tab": true,
"material_theme_tabs_autowidth": true,
"overlay_scroll_bars": "enabled",
"smart_indent": true,
"word_wrap": true
}

subl命令

在Bash中执行下面语句:

1
sudo ln -s /Applications/Sublime\ Text.app/Contents/SharedSupport/bin/subl /usr/local/bin/subl


参考链接

  • Package Control Installation
  • 解决Mac下Sublime Text3因为pyV8无法加载导致Emmet无法使用的问题
  • Sublime Text 插件与快捷键(Mac 版)

1…345…8
archerda

archerda

71 日志
37 标签
GitHub Email
© 2015 - 2019 archerda
由 Hexo 强力驱动
主题 - NexT.Muse