Java Concurrency in Practice 读书笔记 第三章

第三章  共享对象

在有的业务场景,不仅需要线程安全读写对象,还要让其他线程得知对象状态的更改,这必须由线程同步机制来完成。

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) {
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}

尽管主线程很快的更改了number和ready,但是执行结果却是:读线程将挂起很久(因为无法立即看到ready变成true),甚至打印出来0(没有看到number的变化)。这其中的机制较为复杂。

3.1.1  陈旧的数据(Stale value)

更为复杂的是,由于Java内部的优化机制非常复杂,导致变量的可见性甚至是没有顺序的:可能后赋值的变量已经被读取了,但是先更新的变量却还是原来的数值。

因此进行变量控制是非常有必要的:

@ThreadSafe
public class SynchronizedInteger {
    @GuardedBy("this") private int value;

    public synchronized int get() { return value; }
    public synchronized void set(int value) { this.value = value; }
}

必须对getter和setter都添加上synchronized。

3.1.2  64比特的特例

对于64比特的变量,例如double和long,JVM允许将他们编程两次32bit的操作来读取!这会进一步引发问题。因此对于64bit的double和long,必须声明volatile或者锁机制

一点从网上摘得介绍:http://netcome.javaeye.com/blog/648487

Volatile修饰的成员变量在每次被线程访问时,都强迫从共享内存中重读该成员变量的值。而且,当成员变量发生变化时,强迫线程将变化值回写到共享内存。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值。

使用建议:在两个或者更多的线程访问的成员变量上使用volatile。当要访问的变量已在synchronized代码块中,或者为常量时,不必使用。

3.1.3  锁定和可见性

Everything A did in or prior to a synchronized block is visible to B when it executes a synchronized block guarded by the same lock. Without synchronization, there is no such guarantee.

恩,就是这意思,进入锁->修改变量->释放锁,这之后,所有使用同样锁的代码块中,都可以立即看到变量的修改更新结果。

3.1.4  Volatile变量

被声明为Volatile的变量(使用volatile关键字)不会被Cache,任意线程读的话都会得到最新的结果。volatile不是万能的,不能代替synchronized,例如count++在volatile的情况下仍然不是原子操作一般情况下只是为了确保变量的更新能马上被看到。

Volatile的用法多用于判断块/循环的终结:interruption, status flag等等,例如下面的代码:

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

Volatile的用法规则:

1、对变量的写操作,不依赖于当前的值。或者只有一个线程更新这个变量。

2、该变量不与其他变量一同参与类状态的改变。

3、由于某些原因,不需要锁。

3.2  变量发布和外泄(Publication and Escape)

变量发布:让变量能被代码作用域之外的区域访问到。如通过Referance、return等。变量的发布无所谓对错,单需要同步控制。

变量外泄:当变量不应该被别人看到的时候,却被发布了。

当变量外泄时,必须假定外部的函数可能威胁线程安全。

变量外泄有多种情况:容易被忽略的是匿名类。

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

匿名类的new将使this外泄。特别是在外泄之时,ThisEscape还没有被构造完毕。

常见的错误时:在构造函数中启动线程,这回导致this变量外泄。此时新开启的线程与原线程共享同一个this,它具备了在为构造好this之前就访问Object的能力。如果同时start,则可能会导致比较大的问题。

因此最好将线程的启动等需要共享this的操作,放在除构造函数之外的函数中。如果一定要放在构造函数中,将构造函数设置为private,然后单独添加函数来访问新对象,如下图所示:

如下所示:

public class SafeListener {
    private final EventListener listener;

    private SafeListener() {
        listener = new EventListener() {
            public void onEvent(Event e) {
                doSomething(e);
            }
        };
    }

    public static SafeListener newInstance(EventSource source) {
        SafeListener safe = new SafeListener();
        source.registerListener(safe.listener);
        return safe;
    }
}

3.3  线程囚禁(Thread Confinement)

各种麻烦的线程同步问题有一个最简单的办法:不把数据共享给其他线程每个线程独立使用自己的变量,这就是线程囚禁

在JDBC、Swing等之中,都广泛的采用了这种技术解决线程安全问题。

线程囚禁分为几种:

1、自我线程囚禁:由线程自己负责,用的比较少因为限制较多。

2、栈囚禁:是指通过返回本地变量(或副本)等方法,由于永久类型(如int、long)无法用引用的方式表示,所以在函数中返回永久类型一定满足栈囚禁的条件,例如:

public int loadTheArk(Collection<Animal> candidates) {
    SortedSet<Animal> animals;
    int numPairs = 0;
    Animal candidate = null;

    // animals confined to method, don't let them escape!
    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;
}

对于引用类型则更麻烦一下,在这个例子中,我们在函数内new了TreeSet,它只被唯一的animal所引用,而且animal是local变量。因此,仅在一个线程内使用非线程安全对象是安全的

3、使用ThreadLocal

一种更通用的方法是使用ThreadLocal,它存储了每一个线程和对应独立的一份对象拷贝

因此某个线程在ThreadLocal上get到的,是该线程最近一次set上去的值。

ThreadLocal常用来保护容易变动的单件类或全局变量

典型的应用,也是我们基本都会写错的是JDBC的getConnection。

一个应用程序的不通线程可通过Singleton获取Connection,显然每个线程应该获取独立的Connection。这种情况应该使用ThreadLocal。

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

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

通过重载initialValue( )方法,为尚未set过的Thread在get的时候提供初始值。父类ThreadLocal默认返回null。

实际上它也是线程Cache的一种好方法(用ThreadLocal封装个Buffer之类的)。

ThreadLocal在各种框架的多线程Context切换的时候非常有用。

3.4  不变对象

截止目前为止,几乎所有的线程安全隐患都是由多个线程尝试改变某状态所导致的。如果对象的状态是恒定不变的,则可以彻底的解决线程安全问题。

不变对象是指:构造函数之后,状态就不再改变的对象。不变对象只能由构造函数控制状态的改变,因此他们具有固有的线程安全性。

不变对象并非指field都为final的对象,因为字段可能是引用类型,引用不能改变,但引用的对象是可变的。

一个对象被称为可变对象当且仅当下述条件同时成立:

1、状态在构造函数之后便不能改变

2、所有字段都是final,并且已经被初始化完毕。

final对象如果无法被外界获取和改变即可。

不变对象具有较弱的原子特性。

例如前面存储被分解数和因子们的Cache的例子,由于无法将更新待操作数、分解因子放到同一个原子操作内,因此它不是线程安全的。

现在使用final字段来形成不变对象可以在一定程度上解决这个问题,构造一个不变对象同时存储数值和因子:

@Immutable
class OneValueCache {
    private final BigInteger lastNumber;
    private final BigInteger[] lastFactors;

    public OneValueCache(BigInteger i,
                         BigInteger[] factors) {
        lastNumber  = 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);
    }
}

此时,在Servlet中,每次的更新都将构造一个新的OneValueCache的不变对象,它总是原子的从一个状态更新到下一个状态。

此时无需锁机制也能实现线程安全!

3.5  安全发布

正如前面几节讨论的那样,有时候我们需要将类内的数值发布出去,但又要保证线程安全。

例如,使用上面提到的不变对象,将它应用在Servlet中:

@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);
    }
}

注意,要使用volatale修饰变量,以便让某一线程触发的改动马上可见于其他线程

而未经任何同步措施,就发布对象会导致线程不安全:

// Unsafe publication
public Holder holder;

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

这种发布方式会导致非常诡异的结果:有的线程看到了完整构造的holder,有的却看到了没有构造的holder(null)。通常,这是灾难性的。

对可变对象而言,必须使用同步机制。

不可变对象则可以自由的发布(当满足了那些条件后)。

3.5.3  对象的安全发布规则

1、通过static的构造器初始化

2、将field变成volatile或者AtomicReferance

3、filed编程final并合理的构造函数

4、使用锁同步field

JDK中提供了一些安全发布的类的规则:

1、在下列容器put key/value的时候,可以安全的发布给其他线程:Hashtable, synchronizedMap, or Concurrent-Map

2、Vector, CopyOnWriteArrayList, CopyOnWrite-ArraySet, synchronizedList, or synchronizedSet之中put是线程安全的。

3、BlockingQueue和ConcurrentLinkedQueue是线程安全的。

而使用static来初始化是最好的保护方式:

public static Holder holder = new Holder(42);

JVM可以确保这个是线程安全的。

精华在这里,共享对象的几种形式:

1、线程内独享:只在线程内使用,不给其他线程用。

2、共享-只读:可以并发的被多个线程所用,不用添加同步机制,但是任何线程都不能修改它。主要指不变对象。

3、共享-线程安全:内部看守,因此各个线程可以线程安全的获取它。指ThreadLocal。

4、锁:由线程锁同步机制来完成。

Leave a Reply

Your email address will not be published. Required fields are marked *