多线程 N 次写文件
记一次项目中多线程 N 次写文件的愚蠢做法,通过这次愚蠢的编码,从中体会到的就是我们需要对 JDK 里面源码尽量熟悉,多看源码,这样才能提高我们编码的性能。
愚蠢代码如下:
1
public class FileUtils {
2
3
public static synchronized void writeFile(String filePath, String content) {
4
FileOutputStream outStream = null;
5
BufferedWriter bfWriter = null;
6
try {
7
outStream = new FileOutputStream(filePath, true);
8
bfWriter = new BufferedWriter(new OutputStreamWriter(outStream, "UTF-8"));
9
bfWriter.write(content);
10
} catch (Exception e) {
11
e.printStackTrace();
12
} finally {
13
try {
14
if (bfWriter != null) {
15
bfWriter.close();
16
}
17
if (outStream != null) {
18
outStream.close();
19
}
20
} catch (Exception e) {
21
22
}
23
}
24
}
25
26
}
Copied!

问题描述

上述代码封装了一个通用的多线程 N 次追加写文件的类,上述代码虽然可以保证完成任务,但是代码是非常低效。我发现这段代码的问题,也是因为,一开始我在项目里面只需要使用这段代码写一个文件,写入的次数是大概是在 1500 次左右;而后续由于功能需求增加,我需要使用这段代码写多个文件,每个文件写入次数都是 1500 次左右,从而项目运行的效率变得异常缓慢,如龟速运行,让我无法接受。

改进版本一

于是我开始改进,首先,我觉得每次写入的时候都去 new BufferedWriter 实例,这个是不可取的;浪费了 new 对象的时间,其实我们写文件,BufferedWriter 对象 new 一次就可以了。改进代码如下:
1
public class BigFileWriter1 {
2
3
private FileOutputStream outStream = null;
4
private BufferedWriter bfWriter = null;
5
6
private String filePath;
7
8
public BigFileWriter(String filePath) {
9
this.filePath = filePath;
10
open(); // new writer 对象的时候,open 需要使用的对象一次,节约每次写入的时候都去 new 对象的时间。
11
}
12
13
private void open() {
14
try {
15
outStream = new FileOutputStream(this.filePath, true);
16
bfWriter = new BufferedWriter(new OutputStreamWriter(outStream, "UTF-8"));
17
} catch (Exception e) {
18
System.out.println("get big file writer error");
19
e.printStackTrace();
20
}
21
}
22
23
// synchronized 同步方法块, 非常抢眼且多余的 synchronized
24
public synchronized void writeFile(String content) {
25
try {
26
bfWriter.write(content);
27
} catch (Exception e) {
28
e.printStackTrace();
29
}
30
}
31
32
/**
33
*
34
* 该方法没有同步,原因是该方法应该是 main thread 去进行 close 的。
35
* 因为我们需要让所有线程都执行完了,最后才会 close writer 对象
36
*
37
*/
38
public void close() {
39
try {
40
if (bfWriter != null) {
41
bfWriter.close();
42
}
43
if (outStream != null) {
44
outStream.close();
45
}
46
} catch (Exception e) {
47
48
}
49
}
50
51
}
Copied!
经过上述代码改进后,代码效率基本可以了,但是由于对源码不熟悉,如果专业的人看到这段代码,会觉得我们比较业余,因为那个 synchronized 是抢眼且多余的,在专业的人看来会非常的不舒服的。由于 IO 操作是我们日常编程中使用到最多的 API,但是我们却对源码是那么的不熟悉。

JDK 中 Writer 源码分析

我们先来看看 Writer 的构造方法:
1
/**
2
* The object used to synchronize operations on this stream. For
3
* efficiency, a character-stream object may use an object other than
4
* itself to protect critical sections. A subclass should therefore use
5
* the object in this field rather than <tt>this</tt> or a synchronized
6
* method.
7
*/
8
protected Object lock;
9
10
/**
11
* Creates a new character-stream writer whose critical sections will
12
* synchronize on the writer itself.
13
*/
14
protected Writer() {
15
this.lock = this;
16
}
17
18
/**
19
* Creates a new character-stream writer whose critical sections will
20
* synchronize on the given object.
21
*
22
* @param lock
23
* Object to synchronize on
24
*/
25
protected Writer(Object lock) {
26
if (lock == null) {
27
throw new NullPointerException();
28
}
29
this.lock = lock;
30
}
Copied!
从以上源码可以看出,Writer 里面关键的流部分,都会有 lock 锁进行同步;所以,对于同一个 writer instance 是线程安全的;所以我们写同一个文件的时候使用同一个 writer instance 是线程安全的。也就是说我们使用的 Writer、FileWriter、BufferedWriter 是线程安全的。
具体的 write 方法源码分析如下:
1
public void write(String str, int off, int len) throws IOException {
2
synchronized (lock) {
3
char cbuf[];
4
if (len <= WRITE_BUFFER_SIZE) {
5
if (writeBuffer == null) {
6
writeBuffer = new char[WRITE_BUFFER_SIZE];
7
}
8
cbuf = writeBuffer;
9
} else { // Don't permanently allocate very large buffers.
10
cbuf = new char[len];
11
}
12
str.getChars(off, (off + len), cbuf, 0);
13
write(cbuf, 0, len);
14
}
15
}
Copied!
在初始化 Writer Instance 的时候,我们会确定一个同步锁对象,所以只要我们使用的是一个 Writer 对象,则可以保证线程安全。

改进版本二

通过以上源码分析,我们可以很清楚地改进最优的代码如下:
1
public class BigFileWriter {
2
3
private FileOutputStream outStream = null;
4
private BufferedWriter bfWriter = null;
5
6
private String filePath;
7
8
public BigFileWriter(String filePath) {
9
this.filePath = filePath;
10
open(); // new writer 对象的时候,open 需要使用的对象一次,节约每次写入的时候都去 new 对象的时间。
11
}
12
13
private void open() {
14
try {
15
outStream = new FileOutputStream(this.filePath, true);
16
bfWriter = new BufferedWriter(new OutputStreamWriter(outStream, "UTF-8"));
17
} catch (Exception e) {
18
System.out.println("get big file writer error");
19
e.printStackTrace();
20
}
21
}
22
23
// 由于 bufferedWriter 对象是线程安全的,所以不需要 synchronized 关键字。
24
public void writeFile(String content) {
25
try {
26
bfWriter.write(content);
27
} catch (Exception e) {
28
e.printStackTrace();
29
}
30
}
31
32
/**
33
*
34
* 该方法没有同步,原因是该方法应该是 main thread 去进行 close 的。
35
* 因为我们需要让所有线程都执行完了,最后才会 close writer 对象
36
*
37
*/
38
public void close() {
39
try {
40
if (bfWriter != null) {
41
bfWriter.close();
42
}
43
if (outStream != null) {
44
outStream.close();
45
}
46
} catch (Exception e) {
47
48
}
49
}
50
51
}
Copied!

测试

通过以上三种方法进行测试,使用时间如下:
1
big file writer const:114 ms
2
big file writer 1 const:124 ms
3
file utils const:23236 ms
Copied!
大家可以通过如下测试源码自行测试:
通过测试我们可以发现,确实是改进版本二的代码效率最高。

总结

希望大家多看源码,了解 JDK 源码,学习优秀的编码方式。