JAVA线程同步

接触Android开发快半年了,虽然感觉自己已经可以从事基础的android开发,但是java基础还是比较薄弱。之前有一个电话面试,问到几个java题目,感觉自己也只是一知半解。今天特地来将其中一题拿出来仔细归纳,也就是java线程同步的问题。

在此之前需要先知晓几个概念:

  • 原子性
    • 对于原子性的理解就是,判断某个操作是否为原子操作,若是,就说这个操作具有原子性。
    • 原子操作又是什么,一个不会被打断被阻塞的操作叫做原子操作。(不能只依靠代码的复杂度来判断是否为原子操作)
    • 一般来说,把某个操作反翻译为汇编语言,若该操作不能分解为多个汇编指令,那么这个操作就是原子操作。
    • 在Java中,对除了long和double之外的基本类型的简单操作都具有原子性。简单操作就是赋值或者return。
    • 参考http://www.parallellabs.com/2010/04/15/atomic-operation-in-multithreaded-application/
    • 每个对象都包含了一把锁(也叫作”监视器”),它自动成为对象的一部分(不必为此写任何特殊的代码)
    • 每个对象的锁只能同时被一个线程持有
    • 相同的,每个类也有一把锁(作为类的class对象的一部分)
  • 线程安全类
    • 一个类中所有的字段已经通过同步以保护数据,那么就说这个类是“线程安全类”。
    • 即使是线程安全类,也应该特别小心,因为操作的线程之间仍然不一定安全。

线程同步是什么

简而言之,当有几个不同的线程需要同时操作同一个对象,而这几个线程对这个对像有读操作又有写操作的时候,因为操作行为互相穿插,而导致该对象表现出来的状态变得混乱的时候,程序也就发生异常。这个时候就需要线程同步,规定各个线程的操作顺序的逻辑,使对象的状态按照合理的逻辑进行改变。

举例

一个银行账户,同时被两个线程操作,一个存钱100块,一个取钱100块,账户原本余额为0元。这个时候会发生什么呢?先上原始代码:

Account.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Account {
private float myMoney=0;

public void andMoney(float money){
myMoney+=money;
System.out.println(System.currentTimeMillis()+"存钱:"+money);
}

public void subMoney(float money) {
if (myMoney-money<0) {
System.out.println("余额不足!");
return;
}
myMoney-=money;
System.out.println(System.currentTimeMillis()+"取钱:"+money);
}

public void lookMoney() {
System.out.println("当前余额:"+myMoney);
}
}

SyncTest.java

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 SyncTest {
public static void main(String args[]) {
final Account account=new Account();
Thread threadAdd=new Thread(new Runnable() {

public void run() {
while(true){
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
account.andMoney(100);
account.lookMoney();
System.out.println("\n");
}
}
});

Thread threadSub=new Thread(new Runnable() {

public void run() {
while(true){
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
account.subMoney(100);
account.lookMoney();
System.out.println("\n");
}
}
});

threadAdd.start();
threadSub.start();
}
}

截取了部分输出:

余额不足!
当前余额:100.0

1470797314168存钱:100.0
当前余额:100.0

1470797314670取钱:100.0
当前余额:0.0
1470797314670存钱:100.0

当前余额:100.0

1470797315170取钱:100.0
当前余额:100.0

1470797315170存钱:100.0
当前余额:100.0

很明显,输出的非常混乱,根本不合逻辑。这就是线程不同步造成的结果。

如何进行线程同步

线程同步的方法有好几种,下面一一介绍。

注意: 以下几种方法只是说明可以实现线程同步的方法,按照各自的原理,并不一定都能解决例子中的问题。

使用synchronized关键字

synchronized关键字可以用于同步方法,也可以用于同步代码块。

修饰方法

由于java的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。

修改后的Account.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Account {
private float myMoney=0;

public synchronized void andMoney(float money){
myMoney+=money;
System.out.println(System.currentTimeMillis()+"存钱:"+money);
}

public synchronized void subMoney(float money) {
if (myMoney-money<0) {
System.out.println("余额不足!");
return;
}
myMoney-=money;
System.out.println(System.currentTimeMillis()+"取钱:"+money);
}

public void lookMoney() {
System.out.println("当前余额:"+myMoney);
}
}

输出:

1470797960120存钱:100.0
当前余额:100.0

1470797960122取钱:100.0
当前余额:0.0

1470797960622存钱:100.0
当前余额:100.0

1470797960623取钱:100.0
当前余额:0.0

是不是变得有条理了呢。

注: synchronized也可以修饰静态方法,此时若调用该静态方法,会锁住整个类。

修饰代码块

被该关键字修饰的语句块及相关的对象会自动被加上内置锁,从而实现同步。

修改后的Account.java:

注:为了使输出显示正确,将打印语句也放入同步代码块。

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
public class Account {
private float myMoney=0;

public void andMoney(float money){
synchronized (this) {
myMoney+=money;
}
System.out.println(System.currentTimeMillis()+"存钱:"+money);
}

public void subMoney(float money) {
synchronized (this) {
if (myMoney-money<0) {
System.out.println("余额不足!");
return;
}
myMoney-=money;
}
System.out.println(System.currentTimeMillis()+"取钱:"+money);
}

public void lookMoney() {
System.out.println("当前余额:"+myMoney);
}
}

输出:

1470798732610存钱:100.0
当前余额:100.0

1470798732613取钱:100.0
当前余额:0.0

1470798733112存钱:100.0
当前余额:100.0

1470798733113取钱:100.0
当前余额:0.0

1470798733612存钱:100.0
当前余额:100.0

注意: 同步是一种比较高开销的的操作,应该尽量减少同步的内容。如无必要,使用 synchronized 同步关键代码即可。

使用 volatile 关键字修饰变量

Java 还提供了另一个关键字,用来并发访问数据:volatile。这个关键字指明,应用代码使用字段或变量前,必须重新从主内存读取值。同样,修改使用 volatile 修饰的值后,在写入变量之后,必须存回主内存。

修改后的Account.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Account {
private volatile float myMoney = 0;

public void andMoney(float money) {
myMoney += money;
System.out.println(System.currentTimeMillis() + "存钱:" + money);
}

public void subMoney(float money) {
if (myMoney - money < 0) {
System.out.println("余额不足!");
return;
}
myMoney -= money;
System.out.println(System.currentTimeMillis() + "取钱:" + money);
}

public void lookMoney() {
System.out.println("当前余额:" + myMoney);
}
}

部分输出:

余额不足!
1470829036190存钱:100.0
当前余额:100.0
当前余额:100.0

1470829036692取钱:100.0
当前余额:100.0

1470829036692存钱:100.0
当前余额:100.0

1470829037192存钱:100.0
当前余额:200.0

1470829037192取钱:100.0
当前余额:100.0

感觉又不对了呢。因为myMoney += moneymyMoney -= money都不是原子操作,而volatile并不能保证操作为原子性操作。如果严格遵循 volatile 的使用条件 —— 即变量真正独立于其他变量和自己以前的值,在某些情况下可以使用 volatile 代替 synchronized 来简化代码。然而,使用 volatile 的代码往往比使用锁的代码更加容易出错。

使用 volatile 的场景

假定一个对像有一个布尔标记 done ,它的值被一个线程设置而被另一个线程查询,在这种情况下,可以使用加锁的方式:

1
2
3
private boolean done;
public synchronized boolean isDone(){return done;}
public synchronized void setDone(){done=true;}

但如果另外一个线程已经对这个对象加锁,isDone和setDone方法可能阻塞。而我们并不知道什么时候会释放锁。考虑到这个,一个线程可以为这一变量使用独立的lock。但是这也会带来许多麻烦。

这个时候将该字段声明为 volatile 是合理的。

使用重入锁

在JavaSE 5.0中新增了一个java.util.concurrent包来支持同步。ReentrantLock类是可重入、互斥、实现了Lock接口的锁, 它与使用 synchronized 方法和块具有相同的基本行为和语义,并且扩展了其能力。

修改后的Account.java:

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
public class Account {
private float myMoney = 0;
//声明重入锁
private Lock lock=new ReentrantLock();

public void andMoney(float money) {
try {
lock.lock();//加锁
myMoney += money;
System.out.println(System.currentTimeMillis() + "存钱:" + money);
} finally{
lock.unlock();//释放锁
}
}

public void subMoney(float money) {
try{
lock.lock();
if (myMoney - money < 0) {
System.out.println("余额不足!");
return;
}
myMoney -= money;
System.out.println(System.currentTimeMillis() + "取钱:" + money);
}finally{
lock.unlock();
}
}

public void lookMoney() {
System.out.println("当前余额:" + myMoney);
}
}

输出结果:

1470830515803存钱:100.0
当前余额:100.0

1470830515806取钱:100.0
当前余额:0.0

1470830516306存钱:100.0
当前余额:100.0

1470830516307取钱:100.0
当前余额:0.0

1470830516806存钱:100.0
当前余额:100.0

看的出来,结果与使用 synchronized 几乎相同。如果 synchronized 关键字能满足用户的需求,就用 synchronized,因为它能简化代码 。如果需要更高级的功能,就用 ReentrantLock 类。

注意: 要记得及时释放锁。否则会出现死锁。通常在 finall 代码块中释放锁。

使用 ThreadLocal 类管理类的局部字段

修改后的Account.java:

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 Account {

private static ThreadLocal<Float> myMoney=new ThreadLocal<Float>(){
@Override
protected Float initialValue() {
return 0f;
};
};

public void andMoney(float money) {
myMoney.set((myMoney.get()+money));
System.out.println(System.currentTimeMillis() + "存钱:" + money);
}

public void subMoney(float money) {
if (myMoney.get()-money < 0) {
System.out.println("余额不足!");
return;
}
myMoney.set(myMoney.get()-money);
System.out.println(System.currentTimeMillis() + "取钱:" + money);
}

public void lookMoney() {
System.out.println("当前余额:" + myMoney.get());
}
}

输出结果:

余额不足!
当前余额:0.0

1480078228930存钱:100.0
当前余额:100.0

余额不足!
当前余额:0.0

1480078229434存钱:100.0
当前余额:200.0

余额不足!
当前余额:0.0

1480078229934存钱:100.0
当前余额:300.0

余额不足!
当前余额:0.0

1480078230435存钱:100.0
当前余额:400.0

余额不足!
当前余额:0.0

很明显,存钱操作可以进行,而取钱操作却一直余额不足。这是怎么回事呢。因为当线程访问一个被ThreadLocal管理的字段的时候,线程实际拿到的只是这个字段的一个拷贝。也就是说,取钱线程的myMoney的值始终是0;而存钱线程的myMoney因为不受条件的约束所以可以一直正常访问并修改。但是如果此时再开一个线程查看myMoney的余额,得到的仍然是0.

ThreadLocal的常用方法:

  • get():返回当前线程拷贝的局部线程变量的值。
  • initialValue():返回当前线程赋予局部线程变量的初始值。
  • remove():移除当前线程赋予局部线程变量的值。
  • set(T value):为当前线程拷贝的局部线程变量设置一个特定的值。

对比上面的代码,其实很好懂对吧。

所以ThreadLocal和上面其他的同步机制是采取两种不同的思想实现线程同步。至于具体的使用场景,就得看业务需求咯。但至少我们知道,被ThreadLocal管理的变量,被在乎的就只是变量的初值,至于操作结束后的值,就看操作它的线程如何处理了。

总结

一般多个线程访问某个类的私有变量,那么只需要将公有方法的关键代码包含在 synchronized 代码块中即可。
如果在此基础上还需要更好的扩展性,那么可以使用重入锁,即 ReentrantLock 类。
如果只关心变量的初值,那么就使用 ThreadLocal 管理该变量,到时候只需要向各个线程分发变量的拷贝即可。
而 volatile 的使用,正如在 volatile 一节中的例子那样,条件是比较极端的。

本文参考 http://blog.csdn.net/wenwen091100304/article/details/48318699