《Java并发编程实战》笔记03之对象的共享

第二章介绍了如何通过同步来避免多个线程在同一时刻访问相同的数据,而本章将介绍如何共享和发布对象,从而使它们能够安全地由多个线程同时访问。

我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误解是,认为关键字synchronized只能用于实现原子性或者确定“临界区(Critical Section)”。

同步还有另一个重要的方面:内存可见性(Memory Visibility)。

我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且**希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化**。

如果没有同步,那么这种情况就无法实现。你可以通过显示的同步或者类库中内置的同步来保证对象被安全地发布。

3.1可见性

通常,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有些甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

public class NoVisibility{
    private static boolean ready;
    private static int number;
    
    private static class ReaderThread exteds Thread {
        public void run(){
			while(!ready){
                //Thread.yield():线程让步
                Thread.yield();
            }
            System.out.println(number);
        }
    }
    
    public static void main(String[] args){
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}

虽然NoVisibility看起来会输出42,但事实上很可能输出0,或者根本无法终止。这是因为在代码中没有使用足够的同步机制,因此无法保证主线程写入的ready值和number值对于读线程来说是可见的。

因为可能存在**重排序**的问题:

NoVisibility可能会持续循环下去,因为读线程可能永远都看不到ready的值。一种更奇怪的现象是,NoVisibility可能会输出0,因为读线程可能看到了写入ready的值,但却没有看到之后写入number的值,这种现象被称为“重排序(Reordering)”。

只要在某个线程中无法检测到重排序情况(即使在其他线程中可以很明显地看到该线程中的重排序),那么就无法确保线程中的操作将按照程序中指定的顺序来执行。

当主线程首先写入number,然后在没有同步的情况下写入ready,那么读线程看到的顺序可能与写入的顺序完全相反。

只要有数据在多个线程之间共享,就使用正确的同步。

3.1.1失效数据

失效值may引发的问题:可能导致一些严重的安全问题或者活跃性问题。如果对象的引用(例如链表中的指针)失效,那么情况会更复杂。失效数据还可能导致一些令人困惑的故障,例如意料之外的异常、被破坏的数据结构、不精确的计算以及无限循环等。

@NotThreadSafe
public class MutableInteger {
    private int value;
    
    public int get() { return value; }
    public void set(int value) { this.value = value; }
}

以上代码不是线程安全的,因为get和set都是在没有同步的情况下访问value的。与其他问题相比,失效值问题更容易出现:如果某个线程调用了set,那么另一个正在调用get的线程可能会看到更新后的value值,也可能看不到。

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

你问为什么(synchronized)?

  • 一个时间内只能一个线程执行,其他线程访问需等待
  • 一个线程访问object的一个synchronized代码块,另一个线程仍然可以访问该object中的非synchronized方法
  • 一个线程访问synchronized,其他线程对object的其他synchronized方法阻塞访问。
@ThreadSafe
public class SynchronizedInteger {
    @GuardedBy("this") private int value;
    
    public synchronized int get() { return value; }
    public synchronized void set(int value) { this.value = value; }
}

3.1.2非原子的64位操作

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

最低安全性适用于绝大多数变量,但是存在一个例外:非volatile类型64位数值变量(double和long)。Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。

当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。

因此,即使不考虑失效数据问题,在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。

3.1.3加锁与可见性

内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。

现在我们可以进一步理解为什么在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入变量的值对于其他线程来说都是可见的。

否则,如果一个线程在未持有正确锁的情况下读取某个变量,那么读到的可能是一个失效值。

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

3.1.4Volatile变量

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

Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保变量的更新操作通知到其他线程。

当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。

volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

volatile变量对可见性的影响比volatile变量本身更为重要。当线程A首先写入一个volatile变量并且线程B随后读取该变量时,在写入volatile变量之前对A可见的所有变量的值,在B读取了volatile变量后,对B也是可见的。

因此,从内存可见性的角度来看,写入volatile变量相当于退出同步代码块,而读取volatile变量就相当于进入同步代码块。

然而,我们并不建议过度依赖volatile变量提供的可见性。如果在代码中依赖volatiler变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难以理解。

volatile boolean asleep;
...
    while(!asleep){
        countSomeSheep();
    }

以上代码给出了volatile变量的一种典型用法:检查某个状态标记以判断是否退出循环。

为了使这个示例能正确执行,asleep必须为volatile变量。否则,当asleep被另一个线程修改时,执行判断的线程却发现不了。

虽然volatile变量很方便,但也存在一些局限性。volatile变量通常用做某个操作完成、发生中断或者状态的标志。如上代码。

尽管volatile变量也可以用于表示其他的状态信息,但在使用时要非常小心。例如,volatile的语义不足以确保递增操作(count++)的原子性,除非你能确保只有一个线程对变量执行写操作。

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

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

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

3.2发布与逸出

“发布(Publish)”一个对象的意思是指:使对象能够在当前作用域之外的代码中使用。当某个不应该发布的对象被发布时,这种情况就被称为逸出(Escape)。

发布对象的最简单的方法是将对象那个的引用保存到一个公有的静态变量中,以便任何类和线程都能看见该对象。

public static Set<Secret> knownSecrets;

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

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

同样,如果从非私有方法中返回一个引用,那么同样会发布返回的对象。如下所示:

class UnsafeStates{
    private String[] states = new String[] {
        "AK","AL" ...
    };
    public String[] getStates() { return states; }
}

在这个示例中,数组states已经逸出了它所在的作用域,因为这个本应是私有的变量已经被发布了。

当某个对象逸出后,你必须假设有某个类或线程可能会误用该对象。这正是需要使用==封装的最主要原因==:封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变得更难。

最后一种发布对象或其内部状态的机制就是发布一个内部的类实例(隐式的使this引用逸出,不要这么做):

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

当ThisEscape发布EventListener时,也隐含地发布了ThisEscape实例本身,因为在这个内部类的实例中包含了对ThisEscape实例的隐含引用。

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

如果想在构造函数中注册一个事件监听器或启动线程,那么可以使用一个私有的构造器函数和一个公共的工厂方法(Factory Method),从而避免不正确的构造过程。

public class SafeListener {
    private final EventListener listener;
    
    private SafeListener() {
        listener = new EventListener() {
          public void onEvent(Event e) {
              doSomething(e);
          }  
        };
    }
    //因为上面构造函数被声明为private,所以有了下面这个方法去new A();
    public static SafeListener newInstance(EventSource source) {
        SafeListener safe = new SafeListener();
        source.registerListener(safe.listener);
        return safe;
    }
}

3.3线程封闭

当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。

如果仅在单线程内访问数据,就不需要同步。 这种技术被称为**线程封闭(Thread Confinement)**,它是实现线程安全性的最简单方式之一。

当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。

线程封闭技术的另一种常见应用是JDBC(Java Database Connectivity)的Connection对象。JDBC规范并不要求Conncetion对象是线程安全的。

在典型的服务器应用程序中,线程从连接池中获得一个Connection对象,并且用该对象来处理请求,使用完后再将对象返还给连接池。

由于大多数请求(例如Servlet请求或EJB调用等)都是由单个线程采用同步的方式来处理,并且在Connection对象返回之前,连接池不会再将它分配给其他线程,因此,这种连接管理模式在处理请求时隐含地将Connection对象封闭在线程中。

Java语言及其核心库提供了==一些机制来帮助维护线程封闭性,例如局部变量ThreadLocal类==,但即便如此,程序员仍然需要负责确保封闭在线程中的对象不会从线程中逸出。

3.3.1 Ad-hoc线程封闭

这是指,维护线程封闭性的职责完全由程序实现来承担。

Ad-hoc线程封闭是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰或局部变量,能将对象封闭到目标线程上。

当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个单线程子系统。

在volatile变量上存在一种特殊的线程封闭。只要你能确保只有单个线程对共享的volatile变量执行写入操作,那么就可以 安全地在这些共享的volatile变量执行“读取-修改-写入”的操作。

在这种情况下,相当于将修改操作封闭在单个线程中以防止发生竞态条件,并且volatile变量的可见性保证还确保了其他线程能看到最新的值。

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

3.3.2 栈封闭(举例:封闭在方法中)

栈封闭是线程封闭的一种特列,在栈封闭中,只能通过局部变量才能访问对象。

正如封装能使得代码更容易维持不变性条件那样,同步变量也能使对象更易于封闭在线程中。局部变量的固有属性之一就是封闭在执行线程中。

它们位于执行线程的栈中,其他线程无法访问这个栈。

栈封闭(也被称为线程内部使用或者线程局部使用,不要与核心类库中的ThreadLocal混淆)比Ad-hoc线程封闭更易于维护,也更加健壮。

public int loadTheArk(Collection<Animal> candidates) {
    SortedSet<Animal> animals;
    int numPairs = 0;
    Animal candidate = null;
    
    //animals 被封闭在方法中,不要使它们逸出!
    animals = new ThreeSet<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;
}

3.3.3 ThreadLocal类

维持线程封闭性的一种更规范方法是使用ThreadLocal,这个类能使线程中的某个值保存值的对象关联起来。

ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。

ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。

由于JDBC的连接对象不一定是线程安全的,因此,当多线程应用程序在没有协同的情况下使用全局变量时,就不是线程安全的。通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接。

private static ThreadLocal<Connection> connectionHolder 
	= new ThreadLocal<Connection>(){
		public Connection initialValue() {
			return DriverManager.getConnection(DB_URL);
		}
	};

public static Connection getConnection() {
    return connectionHolder.get();
}

以上代码使用ThreadLocal来维持线程封闭性。

当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用这项技术。

当某个线程初次调用ThreadLocal.get()方法时,就会调用initialValue来获取初始值。

从概念上看,你可以将ThreadLocal视为包含了Map<Thread,T>对象,其中保存了特定于该线程的值,但ThreadLocal的实现并非如此。

这些特定于线程的值保存在Thread对象中,当线程终止后,这些值会作为垃圾回收。

假设你需要将一个单线程应用程序移植到多线程环境中,通过将共享的全局变量转换为ThreadLocal对象(如果全局变量的语义允许),可以维持线程安全性。

3.4 不变性

满足同步需求的另一种方法是使用不可变对象(Immutable Object)。

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

不可变对象很简单。它们只有一种状态,并且该状态由构造函数来控制。

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

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

  • 对象创建以后其状态就不能修改
  • 对象的所有域都是final类型
  • 对象时正确创建的(在对象的创建期间,this引用没有逸出)
@Immutable
public final class ThreeStooges {
    private final Set<String> stooges = new HashSet<String>();
    
    public ThreeStooges() {
        stooges.add("Moe");
        stooges.add("Larry");
        stooges.add("Curly");
    }
    
    public boolean isStooge(String name) {
        return stooges.contains(name);
    }
}

以上代码为在可变对象基础上构建的不可变类。

3.4.1 Final域

final用于构造不可变性对象。final类型的域是不能修改的(但如果final域所引用的对象是可变的,那么这些被引用的对象时可以修改的)。

然而,在Java内存模型中,final域还有这特殊的语义。final域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步

3.4.2示例:使用Volatile类型来发布不可变对象

每当需要对一组相关数据以原子方式执行某个操作时,就可以考虑创建一个不可变的类来包含这些数据。

@Immutable
class OneValueCache {
    private final BigInteger lastNumber;
    private final BigInteger[] lastFactors;
    
    public OneValueCache(BigInteger i,
                        BigInteger[] factors) {
        lastName = i;
        lastFactors = Arrays.copyOf(factors, factors.length);
    }
    
    public BigInteger[] getFactors(BigInteger i) {
        if(lastNumber == null || !lastNumber,equals(i)){
            return null;
        }else{
            return Arrays.copyOf(lastFactors, lastFactors.length);
        }
    }
}

如果要更新这些变量,那么可以创建一个新的容器对象,但其他使用原有对象的线程仍然会看到对象处于一致的状态。

以下代码使用了OneValueCache来保存缓存的数值及其因数。

@ThreadSafe
public class VolatileCachedFactorizer implements Servlet {
    private volatile OneValueCache cache = new OneValueCache(null, null);
    
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = cache.getFactors(i);
        if(factors == null) {
			factors = factor(i);
            cache = new OneValueCache(i, factors);
        }
        encodeIntoResponse(resp, factors);
    }
}

与cache相关的操作不会相互干扰,因为OneValueCache是不可变的,并且在每条相应的代码路径中只会访问它一次。通过使用包含多个状态变量的容器对象来维持不变性条件,并使用一个volatile类型的引用来确保可见性,使得Volatile Cached Factorizer在没有显示地使用锁的情况下仍然是线程安全的。

这里volatile的可见性:保证变量被写时其结果其他线程可见。

3.5安全发布

//不安全的发布
public Holder holder;

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

以上代码在没有足够同步的情况下发布对象(不要这么做)。

这种不正确的发布导致其他线程看到尚未创建完成的对象。

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状态的值却是失效的。
  • 情况变得更加不可预测的是,某个线程在第一次读取域时得到失效值,而再次读取这个域时会得到一个更新值,这也是assertSainty抛出AssertionError的原因。

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

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

为了确保对象状态能呈现出一致的视图,就必须使用同步。在没有额外同步的情况下,也可以安全地访问final类型的域。然而,如果final类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。

3.5.3安全发布的常用模式

一个正确构造的对象可以通过以下方式来安全地发布:

  • 在静态初始化函数中初始化一个对象引用
  • 将对象的引用保存到volatile类型的域或者AtomicReferance对象中
  • 将对象的引用保存到某个正确构造对象的final类型域汇总
  • 将对象的引用保存到一个由锁保护的域中(例如Vector,synchronized List)

**线程安全库中的容器类**提供了以下的安全发布保证:

  • 通过将一个键或者值放入HashtablesynchronizedMap或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)。
  • 通过将某个元素放入VectorCopyOnWriteArrayListCopyOnWriteArraySetsynchronizedListsynchronizedSet中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程。
  • 通过将某个元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以将该元素安全地发布到任何从这些队列中访问该元素的线程。

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

public static Holder holder = new Holder(42);

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

3.5.4事实不可变对象

如果对象从技术上来看可变的,但其状态在发布后不会再改变,那么把这种对象称为“事实不可变对象(Effectively Immutable Object)”

例如,Date本身是可变的,但如果将它作为不可变对象使用,那么在多个线程之间共享Date对象时,就可以省去对锁的使用。

假设需要维护一个Map对象,其中保存了每位用户的最近登录时间:

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

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

3.5.5可变对象

不解释

3.5.6安全地共享对象

不解释

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值