Java多线程系列(二)---对象及变量并发访问

在开发多线程程序时,如果每个多线程处理的事情都不一样,每个线程都互不相关,这样开发的过程就非常轻松。但是很多时候,多线程程序是需要同时访问同一个对象,或者变量的。这样,一个对象同时被多个线程访问,会出现处理的结果和预期不一致的可能。因此,需要了解如何对对象及变量并发访问,写出线程安全的程序,所谓线程安全就是处理的对象及变量的时候是同步处理的,在处理的时候其他线程是不会干扰。本文将从以下几个角度阐述这个问题。

  1. 对于方法的同步处理
  2. 对于语句块的同步处理
  3. 对类加锁的同步处理
  4. 保证可见性的关键字——volatile

对于方法的同步处理

对于一个对象的方法,如果有两个线程同时访问,如果不加控制,访问的结果会出乎意料。所以我们需要对方法进行同步处理,让一个线程先访问,等访问结束,在让另一个线程去访问。对于要处理的方法,用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
public class HasSelfPrivateNum {
private int num = 0;
public void addI(String username){
try{
if (username.equals("a")){
num = 100;
System.out.println("a set over!");
Thread.sleep(2000);
}else {
num = 200;
System.out.println("b set over!");
}
System.out.println(username + " num=" + num);
}catch (Exception e){
e.printStackTrace();
}
}
}

public class SelfPrivateThreadA extends Thread{
private HasSelfPrivateNum num;
public SelfPrivateThreadA(HasSelfPrivateNum num){
this.num = num;
}
@Override
public void run() {
super.run();
num.addI("a");
}
}

public class SelfPrivateThreadB extends Thread{
private HasSelfPrivateNum num;
public SelfPrivateThreadB(HasSelfPrivateNum num){
this.num = num;
}
@Override
public void run() {
super.run();
num.addI("b");
}
}

测试方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class HasSelfPrivateNumTest extends TestCase {
public void testAddI() throws Exception {
HasSelfPrivateNum numA = new HasSelfPrivateNum();
// HasSelfPrivateNum numB = new HasSelfPrivateNum();
SelfPrivateThreadA threadA = new SelfPrivateThreadA(numA);
threadA.start();
SelfPrivateThreadB threadB = new SelfPrivateThreadB(numA);
threadB.start();

Thread.sleep(1000 * 3);
}

}

在这个对象中,有一个成员变量num, 如果username是a,则num应该等于100,如果是b,则num应该等于200,threadA与threadB同时去访问addI方法,预期的结果应该是a num=100 b num=200。但是实际的结果如下:

1
2
3
4
a set over!
b set over!
b num=200
a num=200

这是为什么呢?因为threadA先调用addI方法,但是因为传入的参数的是a,所示ThreadA线程休眠2s,这是B线程也已经调用了addI方法,然后将num的值改为了200,这是输出语句输出的是b改之后的num的值也就是200,a的值被b再次修改覆盖了。 这个方法是线程不安全的,我们给这个方法添加synchronized,修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
synchronized public void addI(String username){
try{
if (username.equals("a")){
num = 100;
System.out.println("a set over!");
Thread.sleep(2000);
}else {
num = 200;
System.out.println("b set over!");
}
System.out.println(username + " num=" + num);
}catch (Exception e){
e.printStackTrace();
}
}

其他地方保持不变,现在我们在看一下,结果:

1
2
3
4
a set over!
a num=100
b set over!
b num=200

这个结果是不是就符合预期的结果,调用的顺序也是一致的。 用synchronized可以保证多线程调用同一个对象的方法的时候,是同步进行的,注意是同一个对象,也就是说synchronized的方法是对象锁,锁住的是对象,如果是不同的对象,就没有这个线程不安全的问题。我们在上面的修改的基础上,去掉 synchronized,然后修改测试方法,让两个线程调用不同对象的方法,修改如下:

1
2
3
4
5
6
7
8
9
10
11
public class HasSelfPrivateNumTest extends TestCase {
public void testAddI() throws Exception {
HasSelfPrivateNum numA = new HasSelfPrivateNum();
HasSelfPrivateNum numB = new HasSelfPrivateNum();
SelfPrivateThreadA threadA = new SelfPrivateThreadA(numA);
threadA.start();
SelfPrivateThreadB threadB = new SelfPrivateThreadB(numA);
threadB.start();
Thread.sleep(1000 * 3);
}
}

结果如下:

1
2
3
4
b set over!
b num=200
a set over!
a num=100

因为threadB是不需要休眠的,所以两个线程同时调用的时候,一定是B线程先出结果,这个结果是符合预期的。但是这样是无法证明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
public class MyObject {

synchronized public void methodA(){
try{
System.out.println("begin methodA threadName=" + Thread.currentThread().getName()+
" begin time =" + System.currentTimeMillis());
Thread.sleep(5000);
System.out.println("end");
}catch (InterruptedException e){
e.printStackTrace();
}
}

synchronized public void methodB(){
try{
System.out.println("begin methodB threadName=" + Thread.currentThread().getName() +
" begin time =" + System.currentTimeMillis());
Thread.sleep(5000);
System.out.println("end");
}catch (InterruptedException e){
e.printStackTrace();
}
}
}

public class SynchronizedMethodThread extends Thread{

private MyObject object;

public SynchronizedMethodThread(MyObject object){
this.object = object;
}

@Override
public void run() {
super.run();
if(Thread.currentThread().getName().equals("A")){
object.methodA();
}else{
object.methodB();
}
}
}

测试方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SynchronizedMethodThreadTest extends TestCase {
public void testRun() throws Exception {
MyObject object = new MyObject();
SynchronizedMethodThread a = new SynchronizedMethodThread(object);
a.setName("A");
SynchronizedMethodThread b = new SynchronizedMethodThread(object);
b.setName("B");

a.start();
b.start();

Thread.sleep(1000 * 15);
}

}

A,B两个线程分别调用methodA与methodB, 两个方法也打印出了他们的开始和结束时间。 结果如下:

1
2
3
4
begin methodA threadName=A begin time =1483603953885
end
begin methodB threadName=B begin time =1483603958886
end

可以看出两个方法是同步调用,一前一后,结果无交叉。说明synchronized修饰方法添加的确实是对象锁。 这样,用synchronized修饰的方法,都需要多线程同步调用,但是没用他修饰的方法,多线程还是直接去调用的。也就是说,虽然多线程会同步调用synchronized修饰的方法,但是在一个线程同步调用方法的时候,其他线程可能先调用了非同步方法,这个在某些时候会有问题。比如出现脏读。 A线程先同步调用了set方法,但是可能在set的过程中出现了等待,然后其他线程在get的时候,数据是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
public class PublicVar {

public String username = "A";
public String password = "AA";

synchronized public void setValue(String username,String password){
try{
this.username = username;
Thread.sleep(3000);
this.password = password;
System.out.println("setValue method thread name=" + Thread.currentThread().getName() + " username="
+ username + " password=" + password);
}catch (InterruptedException e){
e.printStackTrace();
}
}

public void getValue(){
System.out.println("getValue method thread name=" + Thread.currentThread().getName() + " username=" + username
+ " password=" + password);
}
}

public class PublicVarThreadA extends Thread {

private PublicVar publicVar;
public PublicVarThreadA(PublicVar publicVar){
this.publicVar = publicVar;
}

@Override
public void run() {
super.run();
publicVar.setValue("B","BB");
}
}

看测试的例子:

1
2
3
4
5
6
7
8
9
10
11
12
public class PublicVarThreadATest extends TestCase {
public void testRun() throws Exception {
PublicVar publicVarRef = new PublicVar();
PublicVarThreadA threadA = new PublicVarThreadA(publicVarRef);
threadA.start();
Thread.sleep(40);
publicVarRef.getValue();
Thread.sleep(1000 * 5);

}

}

期待的结果应该是”A”,”AA”,或者是”B”,”BB”,然而结果是:

1
2
getValue method thread name=main username=B password=AA
setValue method thread name=Thread-0 username=B password=BB

所以,对于同一个对象中的数据读与取,都需要用synchronized修饰才能同步。脏读一定会出现在操作对象情况下,多线程”争抢”对象的结果。 下面,说一些同步方法其他特性,当一个线程得到一个对象锁的时候,他再次请求对象锁,一定会再次得到该对象的锁。这往往出现在一个对象方法里调用这个对象的另一个方法,而这两个方法都是同步的。这样设计是有原因,因为如果不能再次获得这个对象锁的话,很容易造成死锁。这种直接获取锁的方式称之为可重入锁。 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
public class FatherSynService {

public int i = 10;
synchronized public void operateIMainMethod(){
try{
i--;
System.out.println("main print i=" +i);
Thread.sleep(100);
}catch (InterruptedException e){
e.printStackTrace();
}
}
}

public class SonSynService extends FatherSynService{

synchronized public void operateISubMethod(){
try{
while (i > 0){
i--;
System.out.println("sub print i=" + i);
Thread.sleep(1000);
this.operateIMainMethod();
}
}catch (InterruptedException e){
e.printStackTrace();
}
}
}

public class SonSynTread extends Thread{
@Override
public void run() {
super.run();
SonSynService son = new SonSynService();
son.operateISubMethod();
}
}

测试的例子如下:

1
2
3
4
5
6
7
8
public class SonSynTreadTest extends TestCase {
public void testRun() throws Exception {
SonSynTread thread = new SonSynTread();
thread.start();

Thread.sleep(1000 * 10);
}
}

结果就是i是连续输出的。这说明,当存在父子类继承关系时,子类是完全可以通过”可重入锁”调用父类的同步方法的。但是在继承关系中,同步是不会被继承的,也就是说如果父类的方法是同步的方法,然而子类在覆写该方法的时候,没有加同步的修饰,则子类的方法不算是同步方法。 关于同步方法还有一点,就是同步方法出现未捕获的异常,则自动释放锁。

对于语句块的同步处理

对于上面的同步方法而言,其实是有些弊端的,如果同步方法是需要执行一个很长时间的任务,那么多线程在排队处理同步方法时就会等待很久,但是一个方法中,其实并不是所有的代码都需要同步处理的,只有可能会发生线程不安全的代码才需要同步。这时,可以采用synchronized来修饰语句块让关键的代码进行同步。用synchronized修饰同步块,其格式如下:

1
2
3
synchronized(对象){
//语句块
}

这里的对象,可以是当前类的对象this,也可以是任意的一个Object对象,或者间接继承自Object的对象,只要保证synchronized修饰的对象被多线程访问的是同一个,而不是每次调用方法的时候都是新生成就就可以。但是特别注意String对象,因为JVM有String常量池的原因,所以相同内容的字符串实际上就是同一个对象,在用同步语句块的时候尽可能不用String。
下面,看一个例子来说明同步语句块的用法和与同步方法的区别:

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
public class LongTimeTask {
private String getData1;
private String getData2;

public void doLongTimeTask(){
try{
System.out.println("begin task");
Thread.sleep(3000);
String privateGetData1 = "长时间处理任务后从远程返回的值 1 threadName=" + Thread.currentThread().getName();
String privateGetData2 = "长时间处理任务后从远程返回的值 2 threadName=" + Thread.currentThread().getName();

synchronized (this){
getData1 = privateGetData1;
getData2 = privateGetData2;
}

System.out.println(getData1);
System.out.println(getData2);
System.out.println("end task");
}catch (InterruptedException e){
e.printStackTrace();
}
}
}

public class LongTimeServiceThreadA extends Thread{

private LongTimeTask task;
public LongTimeServiceThreadA(LongTimeTask task){
super();
this.task = task;
}

@Override
public void run() {
super.run();
CommonUtils.beginTime1 = System.currentTimeMillis();
task.doLongTimeTask();
CommonUtils.endTime1 = System.currentTimeMillis();
}
}

public class LongTimeServiceThreadB extends Thread{

private LongTimeTask task;
public LongTimeServiceThreadB(LongTimeTask task){
super();
this.task = task;
}

@Override
public void run() {
super.run();
CommonUtils.beginTime2 = System.currentTimeMillis();
task.doLongTimeTask();
CommonUtils.endTime2 = System.currentTimeMillis();
}
}

测试的代码如下:

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
public class LongTimeServiceThreadATest extends TestCase {

public void testRun() throws Exception {
LongTimeTask task = new LongTimeTask();
LongTimeServiceThreadA threadA = new LongTimeServiceThreadA(task);
threadA.start();

LongTimeServiceThreadB threadB = new LongTimeServiceThreadB(task);
threadB.start();

try{
Thread.sleep(1000 * 10);
}catch (InterruptedException e){
e.printStackTrace();
}

long beginTime = CommonUtils.beginTime1;
if (CommonUtils.beginTime2 < CommonUtils.beginTime1){
beginTime = CommonUtils.beginTime2;
}

long endTime = CommonUtils.endTime1;
if (CommonUtils.endTime2 < CommonUtils.endTime1){
endTime = CommonUtils.endTime2;
}
System.out.println("耗时:" + ((endTime - beginTime) / 1000));

Thread.sleep(1000 * 20);
}

}

结果如下:

1
2
3
4
5
6
7
8
9
begin task
begin task
长时间处理任务后从远程返回的值 1 threadName=Thread-1
长时间处理任务后从远程返回的值 2 threadName=Thread-1
end task
长时间处理任务后从远程返回的值 1 threadName=Thread-1
长时间处理任务后从远程返回的值 2 threadName=Thread-1
end task
耗时:3

两个线程并发处理耗时任务只用了3s, 因为只在赋值的时候进行同步处理,同步语句块以外的部分都是多个线程异步处理的。
下面,说一下同步语句块的一些特性:

  1. 当多个线程同时执行synchronized(x){}同步代码块时呈同步效果。
  2. 当其他线程执行x对象中的synchronized同步方法时呈同步效果。
  3. 当其他线程执行x对象中的synchronized(this)代码块时也呈现同步效果。

细说一下每个特性,第一个特性上面的例子已经阐述了,就不多说了。第二个特性,因为同步语句块也是对象锁,所有当对x加锁的时候,x对象内的同步方法也呈现同步效果,当x为this的时候,该对象内的其他同步方法也要等待同步语句块执行完,才能执行。第三个特性和上面x为this是不一样的,第三个特性说的是,x对象中有一个方法,该方法中有一个synchronized(this)的语句块的时候,也呈现同步效果。即A线程调用了对x加锁的同步语句块的方法,B线程在调用该x对象的synchronized(this)代码块是有先后的同步关系。

上面说同步语句块比同步方法在某些方法中执行更有效率,同步语句块还有一个优点,就是如果两个方法都是同步方法,第一个方法无限在执行的时候,第二个方法就永远不会被执行。这时可以对两个方法做同步语句块的处理,设置不同的锁对象,则可以实现两个方法异步执行。

对类加锁的同步处理

和对象加锁的同步处理一致,对类加锁的方式也有两种,一种是synchronized修饰静态方法,另一种是使用synchronized(X.class)同步语句块。在执行上看,和对象锁一致都是同步执行的效果,但是和对象锁却有本质的不同,对对象加锁是访问同一个对象的时候成同步的状态,不同的对象就不会。但是对类加锁是用这个类的静态方法都是呈现同步状态。
下面,看这个例子:

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 StaticService {
synchronized public static void printA(){
try{
System.out.println(" 线程名称为:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 进入printA");
Thread.sleep(1000 * 3);
System.out.println(" 线程名称为:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 离开printA");
}catch (InterruptedException e){
e.printStackTrace();
}
}

synchronized public static void printB(){
System.out.println(" 线程名称为:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 进入printB");
System.out.println(" 线程名称为:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 离开printB");
}

synchronized public void printC(){
System.out.println(" 线程名称为:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 进入printC");
System.out.println(" 线程名称为:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 离开printC");
}
}

测试方法如下:

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 StaticServiceTest extends TestCase {

public void testPrint() throws Exception{
new Thread(new Runnable() {
public void run() {
StaticService.printA();
}
}).start();

new Thread(new Runnable() {
public void run() {
StaticService.printB();
}
}).start();

new Thread(new Runnable() {
public void run() {
new StaticService().printC();
}
}).start();

Thread.sleep(1000 * 3);
}

}

结果如下:

1
2
3
4
5
6
线程名称为:Thread-0 在 1483630533783 进入printA
线程名称为:Thread-2 在 1483630533783 进入printC
线程名称为:Thread-2 在 1483630533783 离开printC
线程名称为:Thread-0 在 1483630536786 离开printA
线程名称为:Thread-1 在 1483630536787 进入printB
线程名称为:Thread-1 在 1483630536787 离开printB

很明显的看出来,对类加锁和对对象加锁两者方法是异步执行的,而对类加锁的两个方法是呈现同步执行。
其特性也和同步对象锁一样。

关于同步加锁的简单使用的介绍就到这里了。最后还有注意一点,锁对象锁的是该对象的内存地址,其存储的内容改变,并不会让多线程并发的时候认为这是不同的锁。所以改变锁对象的内容,并不会同步失效。

保证可见性的关键字——volatile

在多线程争抢对象的时候,处理该对象的变量的方式是在主内存中读取该变量的值到线程私有的内存中,然后对该变量做处理,处理后将值在写入到主内存中。上面举的例子,之所以出现结果与预期不一致都是因为线程自己将值复制到自己的私有栈后修改结果而不知道其他线程的修改结果。如果我们不用同步的话,我们就需要一个能保持可见的,知道其他线程修改结果的方法。JDK提供了volatile关键字,来保持可见性,关键字volatile的作用是强制从公共堆栈中取得变量的值,而不是从线程私有数据栈中取得变量值。但是该关键字并不能保证原子性,以争抢一个对象中的count变量来看下图的具体说明:
变量在线程私有栈与主内存的关系

java 垃圾回收整理一文中,描述了jvm运行时刻内存的分配。其中有一个内存区域是jvm虚拟机栈,每一个线程运行时都有一个线程栈,线程栈保存了线程运行时候变量值信息。当线程访问某一个对象时候值的时候,首先通过对象的引用找到对应在堆内存的变量的值,然后把堆内存变量的具体值load到线程本地内存中,建立一个变量副本,之后线程就不再和对象在堆内存变量值有任何关系,而是直接修改副本变量的值,在修改完之后的某一个时刻(线程退出之前),自动把线程变量副本的值回写到对象在堆中变量。这样在堆中的对象的值就产生变化了。

volatile在此过程中的具体说明如下:

read and load 从主存复制变量到当前工作内存
use and assign 执行代码,改变共享变量值
store and write 用工作内存数据刷新主存相关内容
其中use and assign 可以多次出现
但是这一些操作并不是原子性,也就是 在read load之后,如果主内存count变量发生修改之后,线程工作内存中的值由于已经加载,不会产生对应的变化,所以计算出来的结果会和预期不一样对于volatile修饰的变量,jvm虚拟机只是保证从主内存加载到线程工作内存的值是最新的例如假如线程1,线程2 在进行read,load 操作中,发现主内存中count的值都是5,那么都会加载这个最新的值在线程1堆count进行修改之后,会write到主内存中,主内存中的count变量就会变为6线程2由于已经进行read,load操作,在进行运算之后,也会更新主内存count的变量值为6导致两个线程及时用volatile关键字修改之后,还是会存在并发的情况。
上述对于volatile的解析均摘自java中volatile关键字的含义

本片博客转载自:https://github.com/byhieg/JavaTutorial