《Java并发编程》第三章 — 对象的共享 — 读书笔记

    我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误解是,认为关键字synchronized智能用于实现原子性和确定“临界区(Critical Section)”。同步还有另一个重要的方面:内存可见性(Memory Visibility)。我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。如果没有同步,那么这种情况就无法实现。你可以通过显式的同步或者类库中内置的同步来确保对象被安全的发布。

3.1 可见性

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

public class NoVisibility {
	private static boolean ready;
	private static int number;
	
	private static class ReaderThread extends Thread {
		public void run() {
			while(!ready) 
				Thread.yield();
			System.out.println(number);
		}
	}
	
	public static void main(String[] args) {
		// 从代码上来看,先执行run函数,在设置number和ready的值
		new ReaderThread().start();
		number = 42;
		ready = true;
	}
}

    NoVisibility可能会持续循环下去,因为读线程可能永远都看不到ready的值。一种更奇怪的现象是,NoVisibility可能会输出0,因为读线程可能看到了写入ready的值,但却没有看到之后写入number的值,这种现象被称为“重排序(Reordering)”。只要在某个线程中无法检测到重排序情况,那么就无法确保线程中的操作将按照程序中指定的顺序来执行。当主程序先写入number,然后在没有同步的情况下写入ready,那么读线程看到的顺序可能与写入的顺序完全相反。

在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。

3.1.1 失效数据

    NoVisibility展示了在缺乏同步的程序中可能产生的错误结果的一种:失效数据。当读线程查看ready变量时,可能会得到一个已经失效的值。除非在每次访问变量时都使用同步。否则很可能获得该变量的一个失效值。更糟糕的是,失效值可能不会同时出现:一个线程可能获得某个变量的最新值,而获得另一个变量的失效值。再看来一个例子:

// 线程不安全
public class MutableInteger {
	private int value;

	public int getValue() {
		return value;
	}

	public void setValue(int value) {
		this.value = value;
	}
}

    MutableInteger不是线程安全的,因为get和set都是在没有同步的情况下访问value的。如果某个线程调用了set,那么另一个正在调用get的线程可能会看到更新后的value值,也可能看不到。下面将该类改写成线程安全的:

// 线程安全
public class MutableInteger {
	private int value;

	public synchronized int getValue() {
		return value;
	}

	public synchronized void setValue(int value) {
		this.value = value;
	}
}

    通过对get和set等方法进行同步,可以使MutableInteger成为一个线程安全的类。仅仅对set方法进行同步是不够的,调用get的线程仍然会看到失效值。

3.1.2 非原子的64位操作

    当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性(out-of-thin-airsafety)。

    最低安全性适用于绝大多数变量,但是存在一个例外:非volatile类型的64位数值变量(double和long)。Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。因此,即使不考虑失效数据问题,在多线程程序中使用共享且可变的long和double等类型的变量也不是安全的,除非用关键字volatile来声明他们,或者用锁保护起来。

3.1.3 加锁与可见性

    在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,确保某个线程写入该变量的值对于其他线程来说都是可见的。否则,如果一个线程在未持有正确锁的情况下读取某个变量,那么读到的可能是一个失效值。

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

3.1.4 Volatile变量

    Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器在运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一个重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

    注意,在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。

    volatile变量对可见性的影响比volatile变量本身更为重要。从内存可见性的角度来看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块。然而,并不建议过度依赖volatile变量提供可见性。

    仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量。

    volatile变量的正确使用方式包括:确保他们自身的状态的可见性,确保他们所引用的对象的状态的可见性,以及标识一些重要的程序生命周期事件的发生(例如,初始化或关闭)。

    虽然volatile变量很方便,但也存在一些局限性。volatile变量通常用作某个操作完成、发生中断或者状态的标志。尽管volatile变量也可以用于表示其他的状态信息,但在使用时要非常小心。例如,volatile的语义不足以确保递增操作(count++)的原子性,除非你能确保只有一个线程对变量执行写操作。

加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。

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

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

3.2 发布与逸出

    发布(Publish)一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。在许多情况中,我们要确保对象及其内部状态不被发布。而在某些情况下,我们有需要发布这个对象,但如果在发布时要确保线程安全性,则可能需要同步。发布内部状态会破坏封装性,并使程序难以维持不变性条件。当某个不应该发布的对象被发布时,这种情况就被称为逸出(Escape)。例如:

public static Set<Secret> knownSecrets;
	
public void initialize() {
	knownSecrets = new HashSet<Secret>();
}

    在initialize方法中实例化一个新的HashSet对象,并将对象的引用保存到knownSecrets中以发布对象。

3.2.1 避免内部的可变状态逸出

    当发布某个对象时,可能会间接发布其他对象。如果将一个Secret对象添加到集合knownSecrets中,那么同样会发布这个对象,因为任何代码都可以遍历这个集合,并获得对这个新Secret对象的引用。同样,如果从非私有方法中返回一个引用,那么同样会发布返回的对象。看一段代码:

class UnsafeStates {
	private String[] states = new String[] { "AK", "AL", ... };

	public String[] getStates() {
		return states;
	}
}

    如果按照上面的代码发布states,就会出现问题,因为任何调用者都能修改这个数组的内容。在这个例子中,数组states已经逸出了它所在的作用域,因为这个本应是私有的变量已经被发布了。

    当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。一般来说,如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达其他的对象,那么这些对象也都会被发布。

3.2.2 隐士地使this引用逸出

    最后一种发布对象或其内部状态的机制就是发布一个内部的类实例,例如:

publi class ThisEscape {
	public ThisEscape(EventSource source) {
		source.registerListener {
			new EventListener() {
				public void onEvent(Event e) {
					doSomething(e);
				}
			}
		};
	}
}

不要在构造过程中使this引用逸出。

    当内部的EventListener实例发布时,在外部封装的ThisEscape实例也逸出了。当且仅当对象的构造函数返回时,对象才处于可预测的和一致的状态。因此,当从对象的构造函数中发布对象时,只是发布了一个尚未构造完成的对象。即使发布对象的语句位于构造函数的最后一行也是如此。如果this引用在构造过程中逸出,那么这种对象就被认为是不正确构造。

    可以使用工厂方式来防治this引用在构造过程中逸出。

3.3 线程封闭

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

    在Java语言中并没有强制规定某个变量必须由锁来保护,同样在Java语言中也无法强制将对象封闭在某个线程中。线程封闭式在程序设计中的一个考虑因素,必须在程序中实现。Java语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和ThreadLocal类。但比便如此,程序员仍然需要负责确保封闭在线程中的对象不会从线程中逸出。

3.1.1 Ad-hoc线程封闭

    Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。Ad-hoc线程封闭式非常脆弱的,因为没有任何一种语言特性,能将对象封闭到目标线程上。事实上,对线程封闭对象的引用通常保存在公有变量中。

    当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个单线程子系统。在某些情况下,单线程子系统提供的简便性要胜过Ad-hoc线程封闭技术的脆弱性。

    举个例子,在volatile变量上存在一种特殊的线程封闭。只要你能确定只有单个线程对共享的volatile变量执行写入操作,那么就可以安全的在这些共享的volatile变量上执行“读取-修改-写入”的操作。在这种情况下,相当于修改操作封闭在单个线程中以防止发生竞态条件,并且volatile变量的可见性保证还确保了其他线程能看到最新的值。

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

3.1.2 栈封闭

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


    public int loadTheArk(Collection<Animal> candidates) {
        SortedSet<Animal> animals;
        int numPairs = 0;
        Animal candidate = null;
 
        animals = new TreeSet<Animal>(new SpeciesGenderComparator());
        animals.addAll(candidates);
        for (Animal a : animals) {
            if (candidate == null || !candidate.isPotentialMate(a))
                candidate = a;
            else {
                ark.load(new AnimalPair(candidate, a));
                ++numPairs;
                candidate = null;
            }
        }
        return numPairs;
    }

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

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

3.1.3 ThreadLocal类

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

    ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。例如Connection对象,由于JDBC的连接对象不一定是线程安全的,因此,当多线程应用程序在没有协同的情况下使用全局变量时,就不是线程安全的。通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的链接,例如:


    public int loadTheArk(Collection<Animal> candidates) {
        SortedSet<Animal> animals;
        int numPairs = 0;
        Animal candidate = null;
 
        animals = new TreeSet<Animal>(new SpeciesGenderComparator());
        animals.addAll(candidates);
        for (Animal a : animals) {
            if (candidate == null || !candidate.isPotentialMate(a))
                candidate = a;
            else {
                ark.load(new AnimalPair(candidate, a));
                ++numPairs;
                candidate = null;
            }
        }
        return numPairs;
    }

    在实现应用程序框架时大量使用了ThreadLocal。例如,在EJB调用期间,J2EE容器需要将一个事务上下文(Transaction Context)与某个执行中的线程关联起来。通过将事务上下文保存在静态的ThreadLocal对象中,可以很容易地实现这个功能:当框架代码需要判断当前运行的是哪一个事务时,只需从这个ThreadLocal对象中读取事务上下文。

    开发人员经常滥用ThreadLocal,例如将所有的全局变量都作为ThreadLocal对象,或者作为一种“隐藏”方法参数的手段。ThreadLocal变量类似于全局变量,它能降低代码的课重用性,并在类之间引入隐含的耦合性,因此在使用时需要格外小心。

3.4 不变性

    如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象(Immutable Object)。线程安全性是不可变对象的固有属性之一,它们的不变性条件是由构造函数创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持。

不可变对象一定是线程安全的。

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

    同样,不可变对象也更加安全。如果将一个可变对象传递给不可信的代码,或者将该对象发布到不可信代码可以访问它的地方,那么就很危险 —— 不可信代码会改变它们的状态,更糟的是,在代码中将保留一个对该对象的引用并稍后再其他线程中修改对象的状态。另一方面,不可变对象不会像这样被恶意代码或者有问题的代码破坏,因此可以安全地共享和发布这些对象,而无须创建保护性的副本。

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

    当满足一下条件时,对象才是不可变的:

  • 对象创建以后其状态就不能修改;
  • 对象的所有域都是fianl类型;
  • 对象是正确创建的;

    关键字final可以视为C++中const机制的一种受限版本,用于构造不可变性对象。final类型的域是不能修改的。然而,在Java内存模型中,final域还有着特殊的语义。fianl域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无需同步。

    即使对象时可变的,通过将对象的某些域声明为final类型,仍然可以简化对状态的判断,因此限制对象的可变性也就相当于限制了该对象可能的状态集合。仅包含一个或两个可变状态的“基本不可变”对象仍然比包含多个可变状态的对象简单。通过将域声明为final类型,也相当于告诉维护人员这些域是不会变化的。

除非需要某个域是可变的,否则应将其声明为final域。

3.5 安全发布

    到目前为止,我们重点讨论的是如何确保对象不被发布,例如让对象封闭在线程或另一个对象的内部。当然,在某些情况下我们希望在多个线程之间共享对象,此时必须确保安全的进行共享。看一段代码:

public Holder holder;

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

    这段代码中,将引用对象保存到公有域中,那么还不足以安全得发布这个对象。由于存在可见性问题,其他线程看到的Holder对象将处于一个不一致的状态,即便在该对象的构造函数中已经正确的构造了不变性条件。这种不正确的发布导致其他线程看大尚未创建完成的对象。

3.5.1 不正确的发布:正确的对象被破坏

    你不能指望一个尚未被完全创建的对象拥有完整性。某个观察该对象的线程将看到对象处于不一致状态,然后看到对象的状态突然发生变化,即使线程在对象发布后还没有修改过它。例如:

public class Holder {
    private int n;

    public Holder(int n) {this.n = n;}

    public void assertSanity() {
        if (n != n) throw new AssertionError("this statement is false.");
    }
}

    由于没有使用同步来确保Holder对象对其他线程可见,因此将Holder称为“未被正确发布”。这里面存在两个问题,首先,除了发布对象的线程外,其他线程可以看到的Holder域是一个失效值,因此将看到一个空引用或者之前的旧值。然而,更糟的情况是线程看到Holder引用的值是最新的,但Holder状态的值却是失效的。情况变得更加不可预测的是,某个线程在第一次读取域时得到失效值,而再次读取这个域时会得到一个更新值,这也是assertSainty抛出AssertionError的原因。

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

    由于不可变对象是一种非常重要的对象,因此Java内存模型为不可变对象提供了一种特殊的初始化安全性保证。我们已经知道,即使某个对象的引用对其他线程是可见的,也并不意味着对象状态对于使用该对象的线程来说一定是可见的。为了确保对象状态能呈现出一直的视图,就必须使用同步。

任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。

3.5.3 安全发布的常用模式

    要安全地发布一个对象,对象的引用以及对象的状态必须同事对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:

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

    在线程安全容器内部的同步意味着,在将对象放入到某个容器,例如Vector或synchronizedList时,将满足上述最后一条需求。

3.5.4 事实不可变对象

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

在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。

3.5.5 可变对象

    如果对象在构造后可以修改,那么安全发布只能确保“发布当时”状态的可见性。对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。

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

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

3.5.6 安全的共享对象

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

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

转载于:https://my.oschina.net/u/2450666/blog/737432

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值