并发(currency)中,线程主要通过共享对域的访问实现通信,但是这样的共享,没有问题吗?
[TOC]
1. 共享所带来的问题
1.1 线程干扰(Threads Interference)
public Count {
private int count = 0;
public void plus() {
count++;
}
public void subs() {
count--;
}
public int value() {
return count;
}
}
假设某个Count实例对象在多线程中既调用了plus方法,也调用了subs方法,此时就又可能会出现如题所述的线程干扰,即有如下所述情况:
预期:
- 线程A中调用plus方法,然后打印count的值(通过value方法得到count值),此时count = 1,打印结果为1
- 线程B中调用sub方法,此时count = 0
实际:(其中一种可能)
- 线程A中调用plus方法,暂停执行。此时count = 1
- 线程B开始执行,调用subs方法,此时count = 0
- 线程B执行结束,开始执行线程A,并执行print语句,打印结果为0
从预期及实际执行流程中不难发现,一个多线程程序中,各个线程是交叉执行的,并且如何交叉是无法确定的。这也就导致实际执行结果会和预期结果相差甚远的情况,这就是线程之间发生了干扰。
1.2 内存一致性错误(Memory Consistency Errors)
根据1.1中预期与实际执行流程的对比,我们发现预期和实际执行结果可能不同。
可以看出,预期中是想在线程A和线程B中对count = 0进行操作,但实际却是两个线程执行时,只有其中一个线程是针对count = 0进行的操作,这也就是本节所说的内存一致性错误。
内存一致性错误只在不同线程对同一个数据进行访问时,得到不同的数据是发生。
造成内存一致性错误的原因非常复杂,但好在有一种可以避免发生此种错误的规则——即先行发生(happens-before)关系
如果,操作A先于操作B执行,那么在操作B执行之前,操作A产生的影响都能被操作B观察到。
2. 同步方法(Synchronized Method)
在之前的java并发快速入门中给出了一个简单的同步方法,针对于该同步方法,我们继续深入
public Count {
private int count = 0;
public synchronized void plus() {
count++;
}
public synchronized void subs() {
count--;
}
public sychronized int value() {
return count;
}
}
- 对于Count的同一个实例对象,两个被synchronized标记的方法不可以交错执行的,可以简单的理解为不能同时被调用。
- sychronized标记的方法在执行结束退出时,会自动的建立相对于同一个实例对象随后执行的带有sychronized方法的先行发生关系
NOTE:
在创建线程之间共享的对象时,需要小心过早的泄露对象的引用。这是因为其他线程可能会在对象创建完成之前,通过某种形式访问到该对象。
sychronized是一种阻止线程干扰及内存一致性错误的简单的同步机制,类似于P,V原语操作,但是sychronized修饰符却不仅限于修饰方法,还可以实现加锁同步。
Tip: PV原语操作会在后续的深入中讲解。
3. 内部锁与同步的关系(Intrinsic Locks and Sychronization)
同步是围绕着内部的实体(如内部锁和监视锁)所建立起来的一种机制,声明同步方法只是其一种较为简单的实现。
内部锁在同步中的作用为:
- 互斥访问对象的域值
- 建立先行发生关系
同步方法
一个线程调用同步方法时,会自动为同步方法获取一个锁,并且在方法结束(或return时),立刻释放锁。
NOTE:
static方法,即类方法获取的是Class对象的锁,这点与普通实例方法获取的锁对象(实例方法获取对象锁)不同。
同步块
假设有一个方法:
public void print() {
synchronized(this) {
System.out.println(count);
System.out.println(sum);
System.out.println(mul);
}
}
这个方法需要将count,sum,mul的值输出显示,因为有了synchronized(this)
修饰其后的方法块,故在此方法中,三个打印语句是连续执行的而不允许割裂开来(即原子的)。如果删除同步修饰,则这三条语句便可以分开执行。
synchronized语句还可以提高并发编程时的细粒度,有如下示例:
public class Demo {
private int count1 = 0;
private int count2 = 0; //两个计数器分别用于不同的情况,且不会同时使用
private Object lock1 = new Object();
private Object lock2 = new Object(); //分别为两个不同的锁对象
public void addCount1() {
sycnhronized(lock1) {
count1++;
}
}
public void addCount2() {
sychronized(lock2) {
count2++;
}
}
}
细粒度(fine-grained),可以简单的理解为将某个模型划分为更具体更紧密的对象,如可以将上述示例中synchronized修饰的块看做一个整体,不可再继续划分(也可视为原语),与之对应的有粗粒度
可重用的同步(** Reentrant Sychronization**)
允许一个线程多次申请自身已拥有的锁,称可重用的同步。
NOTE:
此种同步描述了这么一种情况:即一组同步代码直接或间接的调用包含另一组使用相同的锁的同步代码。
换个理解方式则是:一组同步代码中使用的锁可以视为另一组同步代码锁的钥匙,持锁的同步代码可以解锁另一组同步代码,访问其中内容。
注:可重用的不只是同步,还有锁(Reentrant Sychronization)哦!
4. 原语(Atomic)
在上文中曾多次提到过一个名词——原语,其定义为由若干条指令组成的,用于完成一定功能的一个过程,而且要注意该过程是不可中断的。
注意:
- 针对于所有的基本变量类型(不包含long和double)的读和写操作都是原子的。
- 针对于所有声明为volatile的变量(包含long和double)的读和写操作都是原子的。
使用volatile变量可以降低发生内存一致性错误的风险,原因在于任何对volatile变量的写操作都会建立与后续读取该变量的操作的happens-before(先行发生)关系(即volatile变量的改变对于其他线程是可见的,这也意味着当一个线程读取volatile变量时,不仅能看到最新的变化,还能观察到代码副作用导致的变化)。
5. 小结一下
并发式编程中线程间的通信如果不得到有效的管理,那么线程就可能会发生互相干扰的情况,而又由于两个线程间通讯混乱,便又出现了内存一致性的错误,可以说这些都是多线程的弊端。
但是并发式编程带来的巨大好处诱使程序员不断的提出并发式编程的解决方案以便能够在更多的地方使用到并发式,随着同步,加锁,原子操作等一个又一个解决方案的提出,并发式编程技术已经逐渐变得丰满,完善。
但绝不止于此。
网友评论