技术开发 频道

Java并发编程-互斥

  【IT168 技术文章】不同线程的操作在访问共享数据时,会因为交织进行而导致线程干扰和内存一致性错误。大多数Java语句在编译成伪代码后都由多条虚拟机指令组成,这使它们有可能被其他线程的语句所分割交织。不能分割交织的操作乘称作原子动作,这些动作一旦发生,便不能在中途停止,要么完全发生,要么根本不发生,直至动作结束。前文所提到的++操作不是一个原子动作。虽然大部分Java语句都不是原子动作,但是也有一些动作可以认定为是原子性的:

  1.引用类型变量值的读和写。注意这儿是引用值的读写,而不是所引用对象内容的读和写。

  2.除了long和double之外的简单类型的读和写。

  3.所有声明为volatile的变量的读和写,包括long和double类型以及引用类型

  原子动作是不能被交织分割的,因此可以放心使用,不用担心线程干扰问题。但注意内存一致性错误对于原子动作仍然是存在的。使用volatile关键字能够减小内存一致性错误发生的风险,任何对volatile变量的写操作和之后进行的读操作都会自动建立“发生过”关系。这意味着任何对于volatile变量的改变都是对其他线程可见的。另外当某线程读一个volatile变量时,它看到的不仅仅是对该变量的最新改动,也能看到这一改变带来的副作用。

  使用原子变量访问要比使用互斥代码访问要高效得多,但是需要程序员人为地避免内存一致性错误发生。是否需要额外措施避免这些错误往往取决于程序的规模和复杂度。java.util.concurrent包中的类提供了不依赖于互斥原语的方法,在后面的文章我们将逐步介绍。

  内部锁与互斥

  前面提到除少数原子动作能同时避免线程干扰和内存一致性错误外,其它操作都是需要互斥保护才能避免错误的发生。这些保护技术在Java语言中通过互斥方法和互斥代码实现。

  互斥访问机制是建立在内部锁的实体概念上的。API规范通常称这种实体为“管程(monitor)”。内部锁在这两个问题的解决上扮演着重要的角色,它为线程对对象的状态进行强制排他性访问,并建立对于可视性至关重要的“发生过”关系。

  每个对象都有一个内部锁与其对应。如果一个线程需要排他一致性访问对象的字段,它首先要在访问之前获得该对象的内部锁。当访问完成时需要释放该内部锁。线程在获得该锁和释放该锁期间称作拥有该锁。一旦线程拥有内部锁,其他任何线程都不能再获得该锁,它们在获得该锁时会被阻塞。

  当线程释放该内部锁时,“发生过”关系就在该动作和同把锁的后继动作之间建立起来。

  互斥语句

  创建互斥性操作的方法是互斥语句。互斥语句的语法格式如下:

1 synchronized(lock){
2
3   //critical code for accessing shared data.
4
5   //...
6
7   }
8
9

  在Java中,实现互斥语句的关键字叫synchronized(同步),我认为这是一个不合适的术语。同步应该定义为按照固定顺序发生的动作序列。这儿的含义显然是互斥访问的含义。

  这儿lock是提供内部锁的对象。这个语句是互斥代码的一般写法。另外往往整个方法需要进行互斥,这时就有所谓互斥方法。互斥方法根据方法类型的不同分为实例互斥方法和静态互斥方法。实例互斥方法的例子如下:

1 public synchronized void addName(String name){
2
3   //Adding name to a shared list.
4
5   }
6
7   互斥实例方法实际获得的是当前实例对象的内部锁,前面的这个实例方法相当于下面写法的互斥语句:
8
9   public void addName(String name){
10
11   synchronized(this){
12
13   //Adding name to a shared list.
14
15   }
16
17   }
18
19

  静态互斥方法的例子如下:

1  publi class ClassA{
2
3   public static synchronized void addName(String name){
4
5   //Adding to a static shared list.
6
7   }
8
9   }
10
11

  静态互斥方法实际获得的是当前类Class对象的内部锁,前面这个静态方法的相当于下面写法的互斥语句:

1  public class ClassA{
2
3   public static void addName(String name){
4
5   synchronized(ClassA.class){
6
7   //Adding to static shared list.
8
9   }
10
11   }
12
13   }
14
15

  互斥语句在互斥代码开始时获得对象的内部锁,在语句结束或互斥方法返回时释放锁。互斥语句块相对于互斥方法来说主要有两个作用:

  1.避免不必要的死锁。有些被互斥代码块中如果包含其他互斥方法或者代码的调用,可能会造成死锁。

  2.细化互斥的粒度。比如MsLunch有两个实例字段c1和c2从来不一起使用。所有对这些字段的更新必须互斥进行,但没理由防止c1和c2两个字段更新操作的交织,这样也会因不必要的阻塞减小两种操作之间的并发度。可以专门为每个字段定义一个对象锁,而没必要使用和this关联的互斥实例方法:

1 public class MsLunch {
2
3   private long c1 = 0;
4
5   private long c2 = 0;
6
7   private Object lock1 = new Object();
8
9   private Object lock2 = new Object();
10
11   public void inc1() {
12
13   synchronized(lock1) {
14
15   c1++;
16
17   }
18
19   }
20
21   public void inc2() {
22
23   synchronized(lock2) {
24
25   c2++;
26
27   }
28
29   }
30
31   }
32
33

  互斥重入

  注意线程不能获得已经被另一线程所拥有的锁,但线程可以获取它已经拥有的锁。允许线程多次获取同一把锁使互斥方法可以重入,这样互斥代码就能直接或者间接调用另外有互斥代码的方法,而两处互斥代码可以使用同一把锁。如果没有互斥重入机制,我们需要非常小心的编码才能避免这种调用带来的死锁。

  补充

  注意构造函数是不能互斥的。在构造函数前使用synchronized关键字是语法错误。互斥构造函数没有任何意义,因为在其构造时,只有创建该对象的线程可以访问它。在创建要被共享的对象时,一定要注意避免对象的引用提前“泄漏”。比方说想维护一个包含所有实例的静态列表,可能会有这样写代码:

1 public class A{
2
3   public static ArrayList instances=new ArrayList();
4
5   //...
6
7   public A(){
8
9   ...
10
11   instances.add(this);
12
13   ...
14
15   }
16
17   }
18
19

  那么当线程通过new A()生成A的实例时,其他线程可以通过访问A.instances而获得该对象,而该对象目前还没有构建完毕,这时就会造成错误。

  小结

  互斥方法和互斥语句为java提供了简单的防止线程干扰和内存一致性错误的办法,如果一个对象对多个线程可见,所有对该对象的读和写操作都应该通过互斥代码段或互斥方法来实现互斥性访问。

  当然final字段的访问是不需要互斥的。因为一旦初始化完毕,这些字段只能进行读操作,因此可被不同线程之间安全共享。

  这种互斥方式对于避免两种问题非常有效,但同时也带来了其他各种问题。其中最主要的问题就是对线程活性的影响,这些问题通常有死锁(deadlock)、饥饿(starvation)和活琐(livelock)。

  另外代码互斥如果使用不恰当,如互斥粒度掌握不好,就会造成并发度的降低,从而降低整个应用程序的性能。

0
相关文章