logo头像
书院的十三先生

Java并发编程入门(十八)再论线程安全

一、无需加锁的线程安全场景

如下几种场景无需加锁就能做到线程安全:

1.不变对象

2.线程封闭

3.栈封闭

4.ThreadLocal

I、不变对象

经典并发编程描述对象满足不变性有以下条件:

1.对象创建后状态就不再变化。

2.对象的所有域都是final类型。

3.创建对象期间,this引用没有溢出。

实际对于第2点描述不完全准确:

1.只要成员变量是私有的,并且只提供只读操作,就可能做到线程安全,并不一定需要final修饰,注意这里说的是可能,原因见第2点。

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
public class Apple {
public static void main(String[] args) {
Dictionary dictionary = new Dictionary();
Map<String, String> map = dictionary.getMap();
//这个操作后,导致下一步的操作结果和预期不符,预期不符就不是线程安全
map.clear();
System.out.println(dictionary.translate("苹果"));
}
}

class Dictionary {

private final Map<String, String> map = new HashMap<String, String>();

public Dictionary() {
map.put("苹果", "Apple");
map.put("橘子", "Orange");
}

public String translate(String cn) {
if (map.containsKey(cn)) {
return map.get(cn);
}
return "UNKONWN";
}

public Map<String, String> getMap() {
return map;
}
}

因此对不变对象的正确理解应该是:

1.对象创建后状态不再变化(所有成员变量不再变化)

2.只有只读操作。

3.任何时候对象的成员都不会溢出(成员不被其他外部对象进行写操作),而不仅仅只是在构建时。

另一些书籍和培训提到不变类应该用final修饰,以防止类被继承后子类不安全,个人觉得子类和父类本身就不是一个对象,我们说一个类是否线程安全说的是这个类本身,而不需要关心子类是否安全。

II、线程封闭

如果对象只在单线程中使用,不在多个线程中共享,这就是线程封闭。

例如web应用中获取连接池中的数据库连接访问数据库,每个web请求是一个独立线程,当一个请求获取到一个数据库连接后,不会再被其他请求使用,直到数据库连接关闭(回到连接池中)才会被其他请求使用。

III、栈封闭

对象只在局部代码块中使用,就是栈封闭的,例如:

1
2
3
4
5
6
public void print(Vector v) {
int size = v.size();
for (int i = 0; i < size; i++) {
System.out.println(v.get(i));
}
}

变量size是局部变量(栈封闭),Vector又是线程安全的容器,因此对于这个方法而言是线程安全的。

VI、ThreadLocal

通过ThreadLocal存储的对象只对当前线程可见,因此也是线程安全的。

二、常见误解的线程安全场景

I、线程安全容器总是安全

有些容器线程安全指的是原子操作线程安全,并非所有操作都安全,非线程安全的操作如:IF-AND-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
public class VectorDemo {

public static void main(String[] args) {
Vector<String> tasks = new Vector<String>();
for (int i = 0; i < 10; i++) {
tasks.add("task" + i);
}

Thread worker1 = new Thread(new Worker(tasks));
Thread worker2 = new Thread(new Worker(tasks));
Thread worker3 = new Thread(new Worker(tasks));

worker1.start();
worker2.start();
worker3.start();
}
}

class Worker implements Runnable {

private Vector<String> tasks;

public Worker(Vector<String> tasks) {
this.tasks = tasks;
}

public void run() {
//如下操作非线程安全,多个线程同时执行,在判断时可能都满足条件,但实际处理时可能已经不再满足条件
while (tasks.size() > 0) {
//模拟业务处理
sleep(100);
//实际执行时,这里可能已经不满足tasks.size() > 0
System.out.println(Thread.currentThread().getName() + " " + tasks.remove(0));
}
}

private void sleep(long millis) {
try {
TimeUnit.MILLISECONDS.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

输出日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Thread-0 task0
Thread-1 task2
Thread-2 task1
Thread-1 task3
Thread-2 task5
Thread-0 task4
Thread-0 task6
Thread-1 task8
Thread-2 task7
Thread-1 task9
Exception in thread "Thread-0" Exception in thread "Thread-2" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 0
at java.util.Vector.remove(Vector.java:831)
at com.javashizhan.concurrent.demo.safe.Worker.run(VectorDemo.java:46)
at java.lang.Thread.run(Thread.java:745)
java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 0
at java.util.Vector.remove(Vector.java:831)
at com.javashizhan.concurrent.demo.safe.Worker.run(VectorDemo.java:46)
at java.lang.Thread.run(Thread.java:745)

可以看到其中一个工作线程在tasks.remove(0)时,由于集合中已经没有数据而抛出异常。要做到线程安全则要对非原子操作加锁,修改后的代码如下:

1
2
3
4
5
6
7
8
9
public void run() {
//对非原子操作加锁
synchronized (tasks) {
while (tasks.size() > 0) {
sleep(100);
System.out.println(Thread.currentThread().getName() + " " + tasks.remove(0));
}
}
}

II、final修饰的对象线程安全

在上述例子中,即使用final修饰Vector也非线程安全,final不代表被修饰对象是属于线程安全的不变对象。

III、volatile修饰对象线程安全

volatile关键字修饰的对象只能保证可见性,这类变量不缓存在CPU的缓存中,这样能保证如果A线程先修改了volatile变量的值,那么B线程后读取时就能看到最新值,而可见性不等于线程安全。

三、狭义线程安全和广义线程安全

我们说Vector是线程安全的,但上面的例子已经说明:并非所有场景下Vector的操作都是线程安全的,但明明Vector又被公认为是线程安全的,这怎么解释?

由此,我们就可以定义狭义线程安全和广义线程安全:

1.狭义:对象的每一个单个操作线程安全

2.广义:对象的每一个单个操作和组合操作都线程安全

对于上面例子中的Vector要修改为广义线程安全,就需要在remove操作中做二次判断,如果容器中已经没有对象,就返回null,方法签名可以修改为existsAndRemove,当然,为了做到广义线程安全,修改的方法还不仅仅只有这一个。

四、总结

本文描述了不加锁情况下线程安全的场景,以及容易误解的线程安全场景,再到狭义线程安全和广义线程安全,理解了这些,可以让我们更清楚何时该加锁,何时不需要加锁,从而更有效的编写线程安全代码。

end.


Java站点: http://javageektour.com/


微信公众号: