本文的主要内容
-
何谓线程安全?
-
何谓共享可变变量?
-
认识synchronized关键字
-
认识Lock
-
synchronized vs Lock
1.何谓线程安全
多线程是把双刃剑,带来高效的同时,也带来了安全隐患。什么是线程安全?众说一次,很多版本的说辞。引用《Java并发编程实战》书中的定义,如下:当多线程访问时,永远都能表现正确的行为。延伸解读下“何谓正确性”。正确性就是不管是多线程访问,还是单线程访问,影响的结果是一致的。可以将单线程的正确性形容为“所见即所知”。借助下面的例子解释下。
SysnExampleV1.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
40
|
package com.threadexample.sysn;
import java.util.Random;
import java.util.concurrent.TimeUnit;
public class SysnExampleV1 {
static class Task implements Runnable{
private Integer count= 0 ;
private int cycleSize;
public Task( int cycleSize) {
this .cycleSize=cycleSize;
}
@Override
public void run() {
for ( int i= 0 ;i<cycleSize;i++){
this .count++;
}
}
private void doSomething(){
final Random random = new Random();
try {
TimeUnit.MILLISECONDS.sleep(random.nextInt( 10 ));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public int getCount(){
return this .count;
}
}
public static void main(String[] args) throws InterruptedException {
Task task = new Task( 1000 );
Thread t1= new Thread(task);
Thread t2= new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println( "计数(线程数*循环数)=" +task.getCount());
}
} |
Task 类维护一个实例变量count,作为计数器。每循环一次计数加1.一共启用2个线程,每个线程循环1000次。为了保证线程完整执行调用线程的join(),最后的预期效果:2*1000=2000.
测试结果如下(而且结果经常变化)
1
|
计数(线程数*循环数)=1958 |
根据“所见即所知”,2个线程,每个循环1000次,当然是2000了。可结果不是2000.说明Task类不是线程安全的。
简单剖析下原因: 问题出在this.count++,这个操作是符合操作。
使用自带的javap -v SysnExampleV1$Task.class 命令查看字节码文件 ,可以很容易找到原因。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
...
42: getfield #3 // Field count:Ljava/lang/Integer;
45: astore 4
47: aload_3
48: aload_3
49: getfield #3 // Field count:Ljava/lang/Integer;
52: invokevirtual #12 // Method java/lang/Integer.intValue:()I
55: iconst_1
56: iadd
57: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
60: dup_x1
61: putfield #3 // Field count:Ljava/lang/Integer;
...
|
简单的一个自增操作,被分解为
-
获取当前c的值;
-
对获取到的值加1;
-
把递增后的值写回到c;
既然是复合操作,一个线程更新了数据,还没有保存到共享缓存,另外一个线程这时候读取数据,就会存在拿到过期数据的情况。简单演示下,假设count c初始化为0,
线程A:获取c;
线程B:获取c;
线程A:对获取的值加1,结果为1;
线程B:对获取的值加1,结果为1;
线程A:结果写回到c,c现在是1;
线程B:结果写回到c,c现在是1;
按正常理解,B应该写回2才正确。
接下来如何解决这个问题呢。其实很简单。就是用synchronized处理。在介绍synchronized之前,先简单说一下共享变量。
2.何谓共享可变变量
要编写线程安全的代码,其核心在于对状态访问操作的管理上,特别是对共享的和可变的状态的访问。“共享”意味着可以由多个线程同时访问,而”可变“意味着变量的值在其生命周期内可以发生变化。根据不同分类,简单介绍几种变量
局部变量
局部变量存储在线程自己的栈中。也就是说,局部变量永远也不会被多个线程共享。所以,基础类型的局部变量是线程安全的。下面是基础类型的局部变量的一个例子:
1
2
3
4
5
6
|
public void someMethod(){
long threadSafeInt = 0 ;
threadSafeInt++;
} |
局部的对象引用
对象的局部引用和基础类型的局部变量不太一样。尽管引用本身没有被共享,但引用所指的对象并没有存储在线程的栈内。所有的对象都存在共享堆中。所以存在变量逸出现象。关于逸出的相关知识,可以参考《JAVA并发编程实战》3.2节“发布与逸出”。
1
2
3
4
5
6
7
8
9
10
11
|
public void someMethod(){
LocalObject localObject = new LocalObject();
localObject.callMethod();
method2(localObject);
} public void method2(LocalObject localObject){
localObject.setValue( "value" );
} |
样例中LocalObject对象没有被方法返回,也没有被传递给someMethod()方法外的对象。每个执行someMethod()的线程都会创 建自己的LocalObject对象,并赋值给localObject引用。因此,这里的LocalObject是线程安全的。事实上,整个 someMethod()都是线程安全的。即使将LocalObject作为参数传给同一个类的其它方法或其它类的方法时,它仍然是线程安全的。当然,如 果LocalObject通过某些方法被传给了别的线程,那它就不再是线程安全的了。
对象成员
对象成员存储在堆上。如果两个线程同时更新同一个对象的同一个成员,那这个代码就不是线程安全的。下面是一个样例:
1
2
3
4
5
6
7
|
public class NotThreadSafe{
StringBuilder builder = new StringBuilder();
public add(String text){
this .builder.append(text);
}
} |
如果两个线程同时调用同一个NotThreadSafe
实例上的add()方法,就会有竞态条件问题.这时候如果多线程访问对象成语变量,线程就不是线程安全的,所以需要使用java提供的加锁机制进行保护了。目前在Java中存在两种锁机制:synchronized和Lock,Lock接口及其实现类是JDK5增加的内容,其作者是大名鼎鼎的并发专家Doug Lea。本文并不比较synchronized与Lock孰优孰劣。
3.认识synchronized关键字
synchronized是java的一个关键字。java提供了2个同步机制,同步方法和同步块。同步块要关联一个保护的对象,同步方法关联的是this对象。受同步保护的代码块,一次只允许一个线程进入,至于JVM底层又是如何实现synchronized的。
同步机制的建立是基于其内部一个叫内部锁或者监视锁的实体。(在Java API规范中通常被称为监视器。)内部锁在同步机制中起到两方面的作用:对一个对象的排他性访问;建立一种happens-before关系,而这种关系正是可见性问题的关键所在。
每个对象都有一个与之关联的内部锁。通常当一个线程需要排他性的访问一个对象的域时,首先需要请求该对象的内部锁,当访问结束时释放内部锁。在线程 获得内部锁到释放内部锁的这段时间里,我们说线程拥有这个内部锁。那么当一个线程拥有一个内部锁时,其他线程将无法获得该内部锁。其他线程如果去尝试获得 该内部锁,则会被阻塞。
当线程释放一个内部锁时,该操作和对该锁的后续请求间将建立happens-before关系。
更多的原理解释,可以参考深入JVM锁机制之一:synchronized和相关Java memory model知识。
-
使用synchronized块保护方法
代码块1.1
1
2
3
4
5
6
7
|
public void run() {
synchronized ( this ){
for ( int i= 0 ;i<cycleSize;i++){
this .count++;
}
}
} |
或者
代码块1.2
1
2
3
4
5
6
7
|
public void run() {
for ( int i= 0 ;i<cycleSize;i++){
synchronized ( this ){
this .count++;
}
}
}
|
两个方法的区别是synchronized块的保护范围区别,结果是一样的,前者保护的范围大一些,但上下文切换少一些;后者与之相反。具体哪个形式更好,具体要看保护的代码块逻辑了。
2.将方法用synchronized声明
代码块2.1
1
2
3
4
5
|
public synchronized void run() {
for ( int i= 0 ;i<cycleSize;i++){
this .count++;
}
}
|
这种方式,保护效果与代码块1.1达到的效果是一样的。
3.在类级别synchronized 进行保护
代码块3.1
1
2
3
4
5
6
7
|
public void run() {
synchronized (SysnExampleV2. class ) {
for ( int i = 0 ; i < cycleSize; i++) {
this .count++;
}
}
} |
这种方式是4种种最差的。因为它的保护范围最高,并发性最差。此种情景,不适合此种方式。类级别的加锁,一般使用在单例模式(双重校验锁)。一句话,要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性,简单性和性能。
synchronized不能修饰构造函数。
4.认识Lock
与synchronized不同,要手动创建锁,释放锁,获取锁。如下
1
2
3
|
Lock lock = new ReentrantLock();
lock.lock(); //critical section
lock.unlock(); |
SysnExampleV3.java, 展示了Lock的用法。
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
|
package com.threadexample.sysn;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class SysnExampleV3 {
static class Task implements Runnable{
private final Lock lock = new ReentrantLock();
private Integer count= 0 ;
private int cycleSize;
public Task( int cycleSize) {
this .cycleSize=cycleSize;
}
@Override
public void run() {
for ( int i= 0 ;i<cycleSize;i++){
try {
if (lock.tryLock( 10 , TimeUnit.SECONDS)){
this .count++;
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
//一般这样实现
/*
public void run() {
for(int i=0;i<cycleSize;i++){
lock.lock(); // block until condition holds
try {
this.count++;
} finally {
lock.unlock();
}
}
}*/
public int getCount(){
return this .count;
}
}
public static void main(String[] args) throws InterruptedException {
Task task = new Task( 1000 );
Task task2 = new Task( 1000 );
Thread t1= new Thread(task);
Thread t2= new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println( "计数(线程数*循环数)=" +task.getCount());
}
} |
锁像synchronized同步块一样,是一种线程同步机制,但比Java中的synchronized同步块更复杂。java提供了以下的锁。
这几种锁的区别与原理,本文不做深入探讨。
5.synchronized vs Lock
-
synchronized同步块 不提供超时功能,Lock提供了超时功能,使用Lock.tryLock(long timeout, TimeUnit timeUnit)
-
synchronized同步块,使用简单快捷,这一点也造成了它的滥用。可以配合使用wait(),notify()。lock属于JUC的一部分。
-
synchronized造成的线程阻塞,可以被dump,而lock造成的线程阻塞不能dump。
-
synchronized是托管给JVM执行的,而lock是java写的控制锁的代码。在Java1.5中,synchronize是性能低效的。因为这是一个重量级操作,需要调用操作接口,导致有可能加锁消耗的系统时间比加锁以外的操作还多。相比之下使用Java提供的Lock对象,性能更高一些。但是到了Java1.6,发生了变化。synchronize在语义上很清晰,可以进行很多优化,有适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致在Java1.6上synchronize的性能并不比Lock差。官方也表示,他们也更支持synchronize,在未来的版本中还有优化余地。
-
lock有公平锁,非公平锁之分。synchronized只有非公平
-
synchronized原始采用的是CPU悲观锁机制,即线程获得的是独占锁;Lock用的是乐观锁方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁实现的机制就是CAS操作(Compare and Swap)。
-
lock额外提供了Conditon