Fork me on GitHub

JAVA中的线程安全

一、线程安全

个人理解:当多个线程访问一个对象的时候,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象是线程安全的。

JAVA中的线程安全:限定于程序之间存在共享数据访问的这个前提,如果一段代码根本不会与其他线程共享数据,那么从线程安全的角度看,程序无论是串行执行还是多线程执行都是没区别的。

JAVA中线程安全各种操作共享数据有5类:

  • 不可变
  • 绝对线程安全
  • 相对线程安全
  • 线程兼容
  • 线程对立

1、不可变

首先不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要采用任何的线程安全保障措施,java中,如果共享数据是一个基本数据类型,那么只要定义时使用final关键字修饰它就可以保证是不可变的。如果共享数据是一个对象,那么就需要保证对象的行为不会对其状态产生任何影响才行。比如说String类对象,是一个典型的不可变对象,那么我们调用它的substring()、replace()、concat()方法都不会影响它原来的值,只会返回一个新构造的字符串对象。这就是线程安全的。

保证对象行为不影响自己状态的途径有多种,最简单的就是把对象中带有状态的变量都声明为final,这样在构造函数结束之后,他就是不可变的。

1
2
3
4
5
6
7
8
9
10
/*
java.lang.Integer构造函数,通过内部状态变量value定义为final来保证状态不变
*/
public class Integer{
private final int value;

public Integer(int value){
this.value=value;
}
}

在java的API中符合不可变要求的类型,除了String,还有枚举类型,以及java.lang.Number的不容分子类,比如Long,Double等数值包装类型,BigInteger,BigDecimal等大数据类型。

2、绝对线程安全

也就是要求一个类要达到,不管运行时环境如何,调用者都不需要任何额外的同步措施。

比如说java.util.Vector是一个线程安全的容器,因为它的add()/get()/size()这些方法都是被synchronized关键字修饰的,虽然这样做效率低但是安全的。但是,就算它所有的方法都被修饰为同步,也不意味着调用它的时候永远都不需要同步手段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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
public class Test{
private static Vector<Integer> vector=new Vector<Integer>();

public static void main(String[] args){
while(true){
for(int i=0;i<10;i++){
vector.add(i);
}

Thread removeThread=new Thread(new Runnable(){
@Override
public void run(){
for(int i=0;i<vector.size();i++){
vector.remove(i);
}
}
});

Thread printThread=new Thread(new Runnable(){
@Override
public void run(){
for(int i=0;i<vector.size().i++){
System.out.println(vector.get(i));
}
}
});

removeThread.start();
printThread.start();
//不要同时产生过多的线程,否则会导致操作系统假死
while(Thread.activeCount()>20);
}
}
}
/*这段代码会报错,虽然Vector的get(),remove(),size()方法都是同步的,但是多线程的环境中
,如果不在方法调用端做额外的同步措施的话,使用这段代码仍然是不安全的。因为如果
另一个县城恰好在错误的时间里删除了一个元素,导致序号i已经不再可用的话
再用i访问数组就会抛出一个ArrayIndexOutOfBoundsException。
*/

/*修改方法如下:*/
publc class Test{
private static Vector<Integer> vector=new Vector<Integer>();
Thread removeThread=new Thread(new Runnable(){
@Override
public void run(){
synchronized(vector){
for(int i=0;i<vector.size();i++){
vector.remove(i);
}
}
}
});

Thread printThread=new Thread(new Runnable(){
@Override
public void run(){
synchronized(vector){
for(int i=0;i<vector.size();i++){
System.out.println(vector.get(i));
}
}
}
});

removeThread.start();
printThread.start();
while(Thread.activeCount()>20);
}

3、相对线程安全

我们通常意义上说的线程安全是相对线程安全。它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保护措施,但是对于一些有特定顺序的连续调用,就可鞥需要在调用端使用额外的同步的手段来保证调用的正确性。

比如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等都是这样的。

4、线程兼容

线程兼容指的是对象本身不是线程安全的,但是可以通过在调用端使用正确的同步手段来保证对象在并发环境中可以安全的使用。比如与Vector对应得ArrayList,HashTable对象的HashMap都是这种情况。

5、线程对立

线程对立指的是无论调用端是否采用了同步措施,都无法在多线程环境中并发使用的代码。由于java中天生就具备多线程特性,线程对立这种排斥多线程的代码很少,而且都是有害的,应该避免。

二、线程安全的实现方法

1、互斥同步

一种常见的并发正确性保障手段。同步是指在多线程并发访问共享数据时候,保证共享的数据在同一个时刻只能被一个线程使用。而互斥实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)、信号量(Semaphore)都是主要互斥的实现方式。互斥是原因,同步是结果。互斥是方法,同步是目的。

java中:

  • 最基本的互斥同步手段是synchronized关键字,这个关键字是重量级操作,耗时可能比用户代码执行的时间还要长。

  • 使用java.util.concurrent包中的重入锁来实现同步。

虚拟机在未来的性能的改进中,肯定也会更加偏向于原生的synchronized,所以还是提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来实现同步。

2、非阻塞同步

互斥同步最主要的问题是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也叫做阻塞同步。互斥同步是一种悲观的并发策略。总是认为只要不去做正确的同步措施比如加锁,那么肯定就会出现问题,无论共享数据是否真的会出现竞争,他都要进行枷锁,用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

乐观并发措施:基于冲突检测,就是先进行并发操作,如果没有其他线程争用共享数据,那么操作就成功,如果共享数据有争用,就产生了冲突,那么采用其他补偿措施(常见的补偿措施就是不断地重试,直到成功为止),乐观的并发措施策略使得许多实现都不需要把线程挂起。

3、无同步方法

要保证线程安全,并不是一定要进行同步,两者没有因果关系。同步只是保证共享数据争用时正确的手段,如果一个方法本来就不涉及数据共享,那么自然不需要任何同步去保证正确性。