并发编程之对象的共享

本篇文章介绍对象的共享,内容皆总结摘抄自《Java并发编程实战》和《Java并发编程的艺术》,仅作笔记。

同步代码块和同步方法可以确保以原子的方式执行操作,而synchronized不仅可以用于实现原子性或确定临界区,它还可以保证内存可见性。我们不仅希望防止某个线程在使用对象状态时而另一个线程在修改该状态,还希望确保当一个线程修改了对象状态后,其他线程能看到发生的状态变化。

 可见性

可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。在单线程环境中,如果向某个变量写入值,然后在没有其他写入操作的情况下读取这个变量,总能得到相同的值。然而当读操作和写操作在不同的线程中执行时,情况却并非如此。通常,我们无法确保执行读操作的线程能适时的看到其他线程写入的值。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

以下代码说明了当多个线程在没有同步的情况下共享数据时出现的错误。

public class MultiThread {

    private static boolean ready;
    private static int number;

    public static void main(String[] args) {
        new ReaderThread().start();
        number = 22;
        ready = true;
    }

    private static class ReaderThread extends Thread{
        public void run(){
            while(!ready){
                Thread.yield();
            }
            System.out.println(number);
        }
    }
}

主线程和读线程都将访问共享变量ready和number。主线程启动读线程,然后将number设为22,并将ready设为true。读线程一直循环直到发现ready的值变为true,然后输出numer的值。虽然看起来会输出22,但也有可能输出0,或者无法终止。

以上程序可能会有以下三种结果:

  1. 输出22,此结果也就是按照程序正常执行的结果。
  2. 无法终止,因为读线程可能永远没有看到主线程修改的ready的值,因为在代码中没有使用足够的同步机制,因此无法保证主线程写入的ready值和numer值对读线程来说是可见的。
  3. 0,读线程可能看到了主线程修改的ready的值但没有看到在之后修改的number的值,因为修改num与修改ready没有数据依赖关系,因此编译器可能会重排序。

上述程序展示了在缺乏同步的程序中可能产生错误结果的一种情况:失效数据。当多线程查看ready变量时,可能会得到一个失效的值。除非在每次访问变量时都使用同步,否则很可能获得该变量的一个失效值。而且由于重排序的存在,可能会发生一个线程得到了某个变量的最新值,而得到了另一个变量的失效值。

当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性。最低安全性适用于绝大多数变量,但不适用于非volatile类型的64位数值变量(即double类型和long类型)。JMM要求,变量的读取操作和写入操作都必须是原子操作,而对于非volatile类型的double和long变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。

当读取一个非volatile类型的long变量或double变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能读取到某个值的高32位和另一个值的低32位。因此,在多线程程序中使用共享且可变的long和double类型的变量是不安全的,除非用volatile来声明或用锁保护起来。

内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。即当线程A执行某个同步代码块时,线程B随后进入同一个代码块,当线程B执行该代码块时,线程A之前在此代码块中的所有操作结果对线程B都是可见的。如果没有同步,就无法实现上述保证。

加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作和写操作的线程都必须在同一个锁上。

Volatile变量

Java语言提供了一种稍弱的同步机制,即volatile变量,用于确保共享变量的更新操作通知到了其他线程。除了保证可见性,volatile还禁止重排序优化,即volatile修饰的变量上的操作不会与其他内存操作一起重排序。

当写一个volatile变量时,JMM会把该线程对应的本地内存中得共享变量刷新到主内存。当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。

虽然volatile变量很方便,但也存在一些局限性。volatile变量通常用作某个操作完成、发生中断或状态的标志。虽然volatile也可以用于表示其他的状态信息,但在使用时需要非常小心,因为volatile只能确保可见性,无法保证原子性。例如递增操作count++中,即使count使用volatile修饰也只能保证count的修改对其他线程可见,无法保证多个线程同一时间重复执行导致count的值出现偏差。与volatile变量只能确保可见性相比,加锁机制既可以确保可见性又保证原子性。

当且仅当满足以下所有条件时,才应该使用volatile变量:

  1. 对变量的写入操作不依赖变量的当前值,或者确保只有单个线程更新变量的值;
  2. 该变量不会与其他状态变量一起纳入不变性条件中;
  3. 在访问变量时不需要加锁。

发布与逸出

“发布(publish)”一个对象的意思是指使对象能够在当前作用域之外的代码中使用。例如,将一个指向该对象的引用保存到其他代码可以访问的地方或者在某一个非私有方法中返回该引用等等。在许多情况下,我们要保证对象及其内部状态不被发布。而在某些情况下,我们又需要发布某个对象,但如果在发布时要确保线程安全,则可能需要同步。发布内部状态可能会破坏封装性,并使得程序难以维持不变性条件,例如在对象构造完成之前就发布该对象,就会破坏线程安全性。当某个不应该发布的对象被发布时,这种情况就称为逸出(Escape)。

发布对象最简单的方法就是将对象的引用保存到一个公有的静态变量中,以便任何类和线程都能看到该对象。当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。一般来说,如果一个已经发布的对象能够通过非私有的变量引用和方法调用达到其他的对象,那么这些对象都会被发布。

线程封闭

当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步,这种技术称为线程封闭(Thread Confinement)。它是实现线程安全性的最简单方式之一。当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。

线程封闭技术的一种常见应用是JDBC的Connection对象。JDBC规范并不要求Connection对象必须是线程安全的(连接池是线程安全的,连接池通常会由多个线程同时访问)。在典型的服务器应用程序中,线程从连接池获得一个Connection对象,并且用该对象来处理请求,使用完后再将对象返回给连接池。由于大多数请求都是由单个线程采用同步的方式来处理,并且在Connection对象返回之前,连接池不会再将它分配给其他线程,因此这种连接管理模式在处理请求时隐含的将Connection对象封闭在线程中。

在Java语言中无法强制将对象封闭在某个线程中。线程封闭是在程序设计中的一个考虑因素,必须在程序中实现。Java语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和ThreadLocal类,即便如此,我们仍然需要负责确保封闭在线程中的对象不会从线程中逸出。

Ad-hoc线程封闭

Ad-hoc线程封闭是指维护线程封闭性的职责完全由程序实现来承担。Ad-hoc线程封闭是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到目标线程上。

由于Ad-hoc线程封闭技术的脆弱性,因此在程序中尽量少用,在可能的情况下,应该使用更强的线程封闭技术。

栈封闭

栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。正如封装能使得代码更容易维持不变性条件,同步变量也能使对象更易于封闭在线程中。局部变量的固有属性之一就是封闭在执行线程中,它们位于执行线程的栈中,其他线程无法访问这个栈。栈封闭比Ad-hoc线程封闭更易于维护,也更加健壮。

对于基本类型的局部变量,由于任何方法都无法获得对基本类型的引用,因此Java的这种语义确保了基本类型的局部变量始终封闭在线程内。例如下面代码中的count,无论如何都不会破坏栈封闭性。

public static int loadTheArk(List<Integer> intList){
    SortedSet<Integer> intSet;
    int count = 0;
    Integer num = null;

    intSet = new TreeSet<Integer>();
    intSet.addAll(intList);

    for (Integer number:intSet){
        count++;
        if (number % 2 == 0){
            num = 0;
        } else {
            num = number;
        }
        System.out.println(num);
    }
    return count;
}

 而对于维护对象引用的栈封闭性时,我们就需要多做一些工作以确保被引用的对象不会逸出。例如在loadTheArk实例化一个TreeSet对象,并将指向该对象的一个引用保存到inSet中。此处只有一个引用指向集合inSet,这个引用被封闭在局部变量中,因此也被封闭在执行线程中。但如果发布了集合intSet的引用,那么封闭性就被破坏,并且导致了intSet的逸出。

如果在线程内部上下文中使用非线程安全的对象,该对象仍然是安全的。然而只有写这段代码的人才知道哪些对象需要封闭到线程中,以及被封闭的对象是否是线程安全的。这样的代码维护性很差,换一个人维护就很容易错误的使对象逸出。

ThreadLocal类

维护线程封闭的一种更规范方式是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。

不变性

满足同步需求的另一种方法是使用不可变对象。如果某个对象在被创建后其状态就不能被修改,这个对象就称为不可变对象。线程安全性是不可变对象的固有属性之一,因此它们一定是线程安全的。

不可变对象很简单,它们只有一种状态,并且该状态由构造函数来控制。在程序设计中,一个最困难的地方就是判断复杂对象的可能状态,然而判断不可变对象的状态却很简单。

在Java语言规范和JMM中都没有给出不可变的正式定义,但不可变性不等于将对象的所有域都声明为final类型,即使对象中所有的域都是final的,这个对象仍然是可变的,因为在final的域中可以保存对可变对象的引用。

只有满足以下条件时,对象才是不可变的:

  1. 对象创建以后其状态就不能修改;
  2. 对象的所有域都是final类型;
  3. 对象是正确创建的,即this引用没有逸出。

final域

关键字final用于构造不可变对象,final类型的域是不可修改的(如果final域所引用的对象是可变的,那么引用对象是可以修改的)。在JMM中,final还能够确保初始化过程的安全性,从而可以不受限制的访问不可变对象,并在共享这些对象时无需同步。

安全发布

到目前为止,我们重点讨论的是如何确保对象不被发布,例如让对象封闭在线程或另一个对象内部。在某些情况下我们希望在多个线程间共享对象,此时必须确保安全的进行共享。然而如果像如下代码中将对象引用保存到公有域中,还不足以安全的发布这个对象。

public Holder holder;

public void initialize(){
    holder = new Holder(1);
}

由于存在可见性问题,其他线程看到的Holder对象将处于不一致状态,即使在该对象的构造函数中已经正确的构建了不变性条件。这种不正确的发布导致其他线程看到尚未创建完成的对象。由于没有使用同步来确保Holder对象对其他线程可见,因此将Holder称为“未被正确发布”。在未被正确发布的对象中存在两个问题。首先除了发布对象的线程外,其他线程看到的Holder域可能是一个失效值,即看到一个空引用或之前的旧值。更糟糕的情况是,线程看到的Holder引用的值是最新的,但Holder状态的值却是失效的。

如果没有足够的同步,当在多个线程间共享数据时将发生一些非常奇怪的事情。

不可变对象与初始化安全性

JMM为不可变对象的共享提供了一种特殊的初始化安全性保证。即使某个对象的引用对于其他线程来说是可见的,也不意味着对象状态对于使用该对象的线程来说一定是可见的。为了确保对象状态能呈现出一致的视图,就必须使用同步。而在发布不可变对象的引用时没有使用同步也仍然可以安全的访问该对象。

在没有额外同步的情况下,也可以安全的访问final类型的域。然而如果final类型的域指向的是可变对象,那么在访问这些域所指向的对象状态时仍然需要同步。

安全发布的常用模式

可变对象必须通过安全的方式来发布,即在发布和使用该对象的线程时都必须使用同步。要安全的发布一个对象,对象的引用和对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过一下方式来安全的发布:

  • 在静态初始化函数中初始化一个对象引用。
  • 将对象的引用保存到volatile类型的域或AtomicReferance对象中。
  • 将对象的引用保存到某个正确构造对象的final类型域中。
  • 将对象的引用保存到一个由锁保护的域中。

如果线程A将对象x放入一个线程安全的容器,随后线程B读取这个对象,可以确保B看到A设置的x状态,即使在这段读写x的代码中没有显式的同步。

通常,要发布一个静态构造对象,最简单和最安全的方式是使用静态的初始化器:

public static Holder holder = new Holder(1);

静态初始化器由JVM在类的初始化结果执行,由于JVM内部存在同步机制,因此通过这种方式初始化的任何对象都可以被安全的发布。

事实不可变对象

如果对象在发布后就不会被修改,那么对于其他在没有额外同步的情况下安全的访问这些对象的线程来说,安全发布是足够的。所有的安全发布机制都能确保,当对象的引用对所有访问该对象的线程可见时,对象发布时的状态对于所以线程也将是可见的,并且如果对象不再改变,就足以确保任何访问都是安全的。

如果对象从技术上来看是可变的,但其状态在发布后不会再改变,这种对象称为“事实不可变对象(Effectively Immutable Object)”。这些对象不需要满足之前介绍过的不可变性的严格定义。在这些对象发布后,程序只需将它们视为不可变对象即可。

例如Date本身是可变的,但如果将它作为不可变对象使用,在多个线程间共享Date对象时,就可以省去对锁的使用。假设需要维护一个Map对象,其中保存了每位用户的最近登录时间:

public Map<String,Date> lastLogin = Collections.synchronizedMap(new HashMap<String, Date>());

如果Date对象的值中被放入Map后就不会改变,那么synchronizedMap中的同步机制就足以使Date值被安全的发布,并且在访问这些Date值时不需要额外的同步。

可变对象

如果对象在构造后可以修改,那么安全发布只能确保“发布当时”状态的可见性。对于可变对象,不仅在发布对象时需要使用同步,在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。要安全的共享可变对象,这些对象就必须被安全的发布,并且必须是线程安全的或者由某个锁保护起来。

对象的发布需求取决于它的可变性:

  • 不可变对象可以通过任意机制发布。
  • 事实不可变对象必须通过安全方式来发布。
  • 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。

安全的共享对象

在并发程序中使用和共享对象时,可以使用一些使用的策略,例如:

  • 线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
  • 只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
  • 线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。
  • 保护对象。被保护的对象只能通过特有的锁来访问。保护对象包括封装在其他线程安全对象中的对象以及已发布的并且由某个特定锁保护的对象。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值