当前位置 : 主页 > 编程语言 > java >

Thread专题(2) - 共享对象

来源:互联网 收集:自由互联 发布时间:2022-10-15
此文被笔者收录在系列文章 ​​​架构师必备(系列)​​ 中 一般来讲,结合本章的共享发布对象技术和上一章的线程安全技术一起可以创建线程安全类以及使用java.util.concurrent类库

此文被笔者收录在系列文章 ​​​架构师必备(系列)​​ 中

一般来讲,结合本章的共享&发布对象技术和上一章的线程安全技术一起可以创建线程安全类以及使用java.util.concurrent类库构造安全的并发应用程序的基础。

共享其实就是某一线程的数据改变对其它线程可见,否则就会出现脏数据。在使用Synchronized时除了了解它是执行原子化操作的,同样还要理解如何内存可见性。保证内存可见性就要保证数据的read和write由同一个锁进行保护。下面是一个不可预见的输出程序,一般不要这么来做。这里的number和ready对于线程来说可能永远不可见,也可能正确输出。

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

这个例子是很难检查的,但有一个简单的原则,就是只要数据需要跨线程共享,就进行恰当的同步。这个例子中main做为主线程,所以是两个线程在跑。

一、Volatile,final, static变量

volatile是一种同步的弱形式(只保证可见性,并不保证操作的原子性),当声明为volatile类型后,编辑器在运行时会监视这个变量,volatile变量不会缓存在寄存器或缓存在对其他处理器隐藏的地方,所以它总是返回最新的值。访问volatile变量不会加锁,所以不会引起线程的阻塞。写入volatile变量就像退出同步块,读取volatile变量就像进入同步块。相对来说它的用处不是很大。一般用于确保它们所引用的对象状态的可见性,或者用于标识重要生命周期事件的发生。一般只有满足下列所有条件时才会使用:

  • 写入变量时并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值;
  • 变量不需要与其他的状态变量共同参与不变约束;
  • 访问变量时,没有其他的原因需要加锁;

二、发布与逸出

发布一个对象的意思是使它能够被当前范围之外的代码所使用。用线程安全的方法完成这些工作时可能需要同步;如果发布了内部状态,就可能危及到封装性使程序难以维持稳定。一个对象在尚未准备好时就将它发布,这种情况称为逸出。

最常见的发布对象的方式有以下几种:1、对象存储在static域,发布一个对象还会间接影响到存储在此对象中的其他对象。2、把私有对象域放在一个非私有方法中返回。3、最后一种就是内部类。

下面是一种很典型的this逸出:

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

这个例子的特殊之处在于,EventListener会封装在一个新线程中,这样有可能导致 EventListener还未完成构造,ThisEscape就会被外部线程可见。在构造函数中创建线程没有错误,但最好不要立即启动它。如果想在构造函数中注册侦听或启动线程,可以通过一个私有的构造函数和一个公用的工厂方法来实现:

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

三、不可变性

线程间的同步可能发生数据间的不可预见性。不可变对象永远是线程安全的。如果对象的状态无法修改,这些变量也就永远不会变。只有满足下列的条件,才是一个不可变对象。

  • 它的状态不能在创建后被修改;
  • 所有域都是final类型并且它被正确创建(创建期间不会发生this引用的逸出)
  • 但并不意味着把所有域都声明为final就是不可变对象。并注意下“对象不可变”和“对象的引用是不可变的”之间并不等同。程序存储在不可变对象中的状态仍然可以通过替换一个带有新状态的不可变对象的实例得到更新。

    安全发布

    上面的一些技术都在强调不发布对象封装对象,如果想安全发布的话需要考虑很多,如果发布一个不可变对象,即使没有使用同步,仍然是线程安全的。前提是满足不可变性的条件。如果final域指向可变对象,那么访问这些对象的状态时仍然需要同步。

    public class StuffIntoPublic {
    public Holder holder;

    public void initialize() {
    holder = new Holder(42);
    }
    }//这个例子可能导致“局部创建对象”,用下面的代码可以进行测试,如果所holder声明为final就可以解决这个问题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.");
    }
    }

    使用volatile发布不可变对象

    使用final域它使得确保初始化安全性成为可能,初始化安全性让不可变性对象不需要同步就能自由的被访问和共享。

    public class OneValueCache {
    private final BigInteger lastNumber;
    private final BigInteger[] lastFactors;

    //如果没有调用getFactors和构造中的copyOf就不是不可变对象
    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);
    }
    }

    通过使用不可变对象来持有所有的变量,可以消除在访问和更新这些变量时的竞争条件。如果是可变的容器就必须考虑加锁。如果是不可变对象,一旦一个线程获得了它的引用,永远不必担心其他线程会修改它的状态。如果更新变量,会创建新的容器对象,不过在此之前任何线程都还和原先的容器打交道,仍然可以看到一致的状态。这是一种去除加锁的简单方法,是一种弱同步实现形式。

    当一个线程设置volatile类型的cache域引用到一个新的OneValueCache后,新数据会立即对其他线程可见。不可变的容器对象持有与不变约束相关的多个状态变量,并利用volatile引用确保及时的可见性,这样虽然没显式地加锁,但线程仍然是安全的。

    @ThreadSafe
    public class VolatileCachedFactorizer extends GenericServlet 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);
    }

    安全发布的模式

    如果发布一个可变对象,那么必须安全的发布,发布线程和消费线程都必须同步化。我们必须解决对象发布后对其修改的可见性问题,对象的引用以及对象的状态必须同时对其他线程可见:

    通过static初始化器初始化对象的引用

    将它的引用存储到volatile或AtomicReference中,通过 AtomicReference<V>将一个对象的所有操作转化成原子操作。

    将它的引用存储到正确创建的对象的final域中

    将它的引用存储到由锁正确保护的域中(线程安全容器的内部同步必须遵守这一条)

    有些代码并没有显式的同步,比如线程安全库中的容器提供了如下的线程安全保证:

    • 置入Hashtable、synchronizedMap、ConcurrentMap中的key及value会安全地发布到可以从Map获得它们的任意线程中,无论是直接获得还是通过iterator获得。
    • 置入Vector、CopyOnWriteArrayList、CopyOnWriterArraySet、SynchronizedList、SynchronizedSet中的元素,会安全发布到可以从容器中获得它的任意线程中。
    • 置入BlockingQueue、ConcurrentLinkedQueue的元素,会安全发布到可以从队列中获得它的任意线程中

    通常最简单的安全发布方式是定义static变量创建对象,因为静态初始化器由JVM在类的初始化阶段执行,由JVM内在的同步机制来确保安全发布。如果一个对象在技术上是可变的,但使用时是不可变的(称为高效不可变对象),这时就可以考虑不给它加锁,可以提高性能,java类库中的Date是个可变对象,如果置入到安生性Map中后就不会改变,需要注意一下。这种技术通常是由业务上决定的。

    安全地共享对象

    设计线程程序时,分析时就要知道哪些对象是做什么,是否需要同步,是否业务上也需要同步。这些分析后才能开始设计。共享对象的规则可以总结如下:

    • 线程限制:一个线程限制的对象,通过限制在线程中,而被线程独占,且只能被占有它的线程修改。
    • 共享只读:一个共享的只读对象,在没有额外同步的情况下,可以被多个线程并发地访问,但是任何线程都不能修改它,共享只读对象包括可变对象与高效不可变对象。
    • 共享线程安全:一个线程安全的对象在内部进行同步,所以其他线程无额外同步,就可以通过公共接口随意访问它。
    • 被守护的:一个被守护的对象只能通过特定的锁来访问,被守护的对象包括那些被线程安全对象封装的对象,和已被特定锁保护起来的已发布对象。

    四、线程封闭

    线程封闭技术是实现线程安全的最简单的方式之一(不共享数据)。当任何一个对象封闭在一个线程中时,自动成为线程安全的。swing采用了线程封闭技术。swing的可视化组件和数据模型对象并不是线程安全的,它们是通过将它们限制到swing的事件分发线程中,实现线程安全的。swing提供了invokeLater机制,用于在事件线程中安排执行Runnable。很多swing应用的并发错误都滋生于从其他线程中错误地使用这些被限制的对象。

    另一种相似的封闭机制就是JDBC池管理。Connection本般也不是线程安全的,然而线程总是从池中获得一个Connection对象,并且用它处理一个单一的请求最后归还,并且在 Connection对象被归还前,池不会将它再分配给其他线程。

    AD-hoc(非正式的)线程限制

    是指维护线程限制性的任务全部落在实现上(程序员)的这种情况。没有可修饰符和相关的API可用。但这种方式非常容易出问题,因此应该有节制地使用它。可能的话可以用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

    维护线程限制的更规范的方式就是使用ThreadLocal(空间换时间),每个Thread的对象都有一个ThreadLocalMap,当创建一个ThreadLocal的时候,就会将该ThreadLocal对象添加到该Map中,为每个使用它的线程维护一份单独的拷贝,所以get总是返回由当前执行线程通过set设置的最新值。

    Thread专题(2) - 共享对象_多线程

    在JDK早期的设计中,每个ThreadLocal都有一个map对象,将线程作为map对象的key,要存储的变量作为map的value,但JDK8之后,每个Thread维护一个ThreadLocalMap对象,这个Map的key是ThreadLocal实例本身,value是存储的值要隔离的变量,是泛型,其具体过程如下:

    • 每个Thread线程内部都有一个Map(ThreadLocalMap::threadlocals);
    • Map里面存储ThreadLocal对象(key)和线程的变量副本(value);
    • Thread内部的Map由ThreadLocal维护,由ThreadLocal负责向map获取和设置变量值;
    • 对于不同的线程,每次获取副本值时,别的线程不能获取当前线程的副本值,就形成了数据之间的隔离。

    JDK8之后设计的好处在于:

    • 每个Map存储的Entry的数量变少,在实际开发过程中,ThreadLocal的数量往往要少于Thread的数量,Entry的数量减少就可以减少哈希冲突。
    • 当Thread销毁的时候,ThreadLocalMap也会随之销毁,减少内存使用,早期的ThreadLocal并不会自动销毁。

    ThreadLocal变量通常用于防止在基于可变的单体或全局变量的设计中出现不正确的共享。比如:JDBC没有规范Connection一定是线程安全的,所以需要额外的协调,通过ThreadLocal把Connection存储起来。

    private ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>(){
    public Connection initialValue(){
    try{
    return DriverManager.getConnection(DB_URL);
    } catch (SQLException e){
    throw new RuntimeException("Unable Connection,");
    }
    };

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

    public static void main(String []args){
    new ConnectionDispenser().getConnection();
    }

    ThreadLocal存储(拷贝)了与Connection相关的值,线程终止后,这些值会被回收 。如果把一个单线程应用迁移到多线程环境中,可以考虑将共享的全局变量都转化为ThreadLocal<T>类型。另一个简单的应用就是J2EE程序中,J2EE容器把一个事务上下文与一个可执行线程关联起来。它利用静态ThreadLocal持有事务上下文;当框架代码需要知道正在运行的哪个事务时,只要从ThreadLocal中获得即可,带来了方便但增加了代码的耦合。

    ThreadLocal还可用于一个频繁执行的操作即需要像buffer这样的临时对象或操作代价异常昂贵,同时还需要避免每次都重分配该临时对象。如果为临时缓存这种简单的事物而使用并没有优势。下面的例子是一个串行调用,从Service1入口,依次调用2,3,4。他们取出的用户全是jack,因为是基于线程的。

    package com.kong.threadlocal;


    public class ThreadLocalDemo05 {
    public static void main(String[] args) {
    User user = new User("jack");
    new Service1().service1(user);
    }

    }

    class Service1 {
    public void service1(User user){
    //给ThreadLocal赋值,后续的服务直接通过ThreadLocal获取就行了。
    UserContextHolder.holder.set(user);
    new Service2().service2();
    }
    }

    class Service2 {
    public void service2(){
    User user = UserContextHolder.holder.get();
    System.out.println("service2拿到的用户:"+user.name);
    new Service3().service3();
    }
    }

    class Service3 {
    public void service3(){
    User user = UserContextHolder.holder.get();
    System.out.println("service3拿到的用户:"+user.name);
    //在整个流程执行完毕后,一定要执行remove
    UserContextHolder.holder.remove();
    }
    }

    class UserContextHolder {
    //创建ThreadLocal保存User对象
    public static ThreadLocal<User> holder = new ThreadLocal<>();
    }

    class User {
    String name;
    public User(String name){
    this.name = name;
    }
    }

    执行的结果:

    service2拿到的用户:jack
    service3拿到的用户:jack

    注意点:大量使用线程本地变量会降低重用性,引入隐晦的类间的耦合。


    【文章转自韩国站群多ip服务器 http://www.558idc.com/krzq.html处的文章,转载请说明出处】
    上一篇:【教程】如何加入 Windows 预览体验计划
    下一篇:没有了
    网友评论