java多线程2.线程安全之可见性

Posted shanhm1991

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了java多线程2.线程安全之可见性相关的知识,希望对你有一定的参考价值。

要编写正确的并发程序,关键在于:在访问共享的可变状态时需要进行正确的管理

可见性: 同步的另一个重要目的——内存可见性。

我们不仅希望防止某个线程正在使用对象状态而另一个线程同时在修改状态,而且希望当一个线程修改了对象状态后,其他线程能够看到发生的状态变化(互斥访问/通信效果)

  • 问题
/**
 * 主线程和读线程都访问共享变量ready和number,主线程启动读线程,然后将number设置为42,
 * 并将ready设为true.读线程一直循环直到发现ready的值变为true,然后输出number的值。
 * 但是代码中没有使用同步机制,因此无法保证主线程写入的ready值和number对于读线程是可见的,
 * 看起来会输出42,但很可能输出0或者根本无法终止。
 * 
 * 因为在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。
 * 在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。
 */
public class Demo {

    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;
    }
}
  • 非原子的64位操作

以上展示了在缺乏同步的程序中可能产生错误结果的一种情况:失效数据。

当读线程查看ready时,可能会得到一个已经失效的值,除非在每次访问时都使用同步。更糟的情况是,一个线程获得变量的最新值,而另一个线程获得变量的失效值。

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

最低安全性适用于绝大多数变量,但是存在一个例外:非volatile类型的64位数值变量(double和long)

java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,jvm允许将64位的读操作或写操作分解为两个32位的操作,就是说即使不考虑失效数据的问题,在多线程程序中使用共享可变的long和double等类型变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。

  • 加锁与可见性

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

当线程A执行某个同步代码块时,线程B随后进入由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被释放之前,A看到的变量值在B获得锁后同样可以由B看到

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

  • volatile变量

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

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

尽管volatile变量也可以用于表示其他的状态信息,但在使用时要非常小心。

例如,volatile的语义不足以确保递增操作++count的原子操作,除非你能确保只有一个线程对变量执行写操作。

正确的使用volatile变量需要满足以下条件:

  1. 对变量的写入操作不依赖变量的当前值(读-改-写),或者你能确保只有单个线程更新变量的值。
  2. 该变量不会与其他状态变量一起纳入不变性条件中。
  3. 访问变量时不需要加锁。
  • 发布与逸出 

发布一个对象是指:使对象能够在当前作用域之外的代码中使用。

如将一个指向该对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,又或者将引用传递到其他类的方法中。 

  • 发布

发布对象最简单的方法是将对象的引用保存到一个公有的静态变量中,当发布某个对象时,可能会间接的发布其他对象。如示例中将一个Secret对象添加到集合中,那么同样会发布这个对象,因为任何代码都可以遍历这个集合。

    public static Set<Secret> knownSecrets;
    
    public void init(){
        knownSecrets = new HashSet<Secret>();
    }  
  • 逸出

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

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

    private String[] states = new String[]{"ak","al"};
    
    public String[] getStates(){
        return states;
    }
  • this逸出

示例中,当Demo发布EventListener时,也隐含地发布了Demo实例本身,因为在这个内部类的实例中包含了对Demo实例的隐含引用this。当且仅当对象的构造函数返回时,对象才处于可预测的和一致的状态,因此,当从对象的构造函数中发布对象时,只是发布了一个尚未构造完成的对象。如果this引用在构造过程中逸出,那么这种对象就被认为是不正确构造。如果实际中这种情况不可避免,可以尝试在构造函数中初始化volatile变量(未验证),因为volatile变量可以避免指令重排序。

public class Demo{
 
    public Demo(EventSource source){
        source.registerListener(){
            new EventListener(){
                public void onEvent(Event e){
                    doSomething(e);
                }
            };
        }
    }
}
  • 避免对象在构造过程中逸出

如果想在构造函数注册一个事件监听器或启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法,从而避免不正确的构造过程。其实在构造函数中创建线程并没有错误,但最好不要立即启动它,而是通过一个start或者initialize方法来启动。

public class Demo{
 
    private final EventListener listener;
    
    private Demo(){
        listener = new EventListener(){
            public void onEvent(Event e){
                dosomething(e);
            }
        };
    }
    
    public static Demo getInstance(EventSource source){
        Demo demo = new Demo();
        source.registerListener(demo.listener);
        return demo;
    }
}
  • 线程封闭:

显然如果仅在单线程内访问数据,就不需要同步,称之为线程封闭,它是实现线程安全性的最简单方式之一

例如:

swing的可视化组件和数据模型对象都不算线程安全的,swing通过将他们封闭到swing的事件分发线程中来实现线程安全性。

JDBC规范并不要求Connection对象必须是线程安全的。在典型的服务器应用程序中,线程从连接池中获得一个Connection对象,并且用该对象来处理请求,使用完后返回连接池。

大多数请求如Servlet或EJB调用等都是由当个线程采用同步的方式来处理,并且在Connection对象返回之前,连接池不会再将它分配给其他线程。

在java语言中并没有强制规定某个变量必须由锁来保护,也无法强制对象封闭在某个线程中。线程封闭是在程序设计中的一个考虑因素,必须在程序中实现。

java语言及其核心库提供了一些机制来维护线程封闭性,如局部变量和ThreadLocal类。

  •  Ad-hoc线程封闭:指维护线程封闭性的职责完全由程序实现来承担。

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

  • 栈封闭:局部变量固有的属性之一就是封闭在线程之中。

但是在维护对象引用的栈封闭时,需要多做一些操作以确保被引用的对象不会逸出。

  • ThreadLocal:使线程中的某个值与保存值的对象关联起来。

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

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

例如,在单线程应用程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用每个方法时都用传递一个Connection对象。
由于JDBC的连接对象不一定是线程安全的。因此,当多线程应用程序在没有协同的情况下使用全局变量时,就不是线程安全的。而通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接。
当某个线程初次调用ThreadLocal.get()方法时,就会调用init()来获取初始值。
你可以将ThreadLocal<T>视为包含了Map<Thread,T>对象,其中保存了特定于该线程的值,但ThreadLocal并非如此。这些特定于线程的值保存在Thread对象中,当线程终止后,这些值会作为垃圾回收。
当某个频繁执行的操作需要一个临时对象,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用这种方法

    private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>(){
        @Override
        public Connection initialValue(){
            try {
                return DriverManager.getConnection("DB_URL");
            } catch (SQLException e) {
                e.printStackTrace();
                return null;
            }
        }
    };
    
    public static Connection getConnection(){
        return connectionHolder.get();
    }

 

  • final域

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

在“不可变的对象”与“不可变的对象引用”之间存在着差异。保存在不可变对象中的对象状态仍然可以更新,即通过将一个保存新状态的实例来“替换”原有的不可变对象。fianl类型的域是不能修改的,但如果final域所引用的对象是可变的,那么这些被引用的对象是可以修改的

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

对于访问和更新多个相关变量出现的竞态条件时,可以通过将这些变量全部保存在一个不可变的对象中来消除。如果是一个可变的变量就必须使用锁来确保原子性。而如果是一个不可变对象,那么当线程获得了该对象的引用后,就不必担心另一个线程会修改对象的状态,如果要更新这些变量,那么可以创建一个新的容器对象,其他使用原有对象的线程仍然会看到对象处于一致的状态。再将这个对象设置成volatile,那么当一个线程更新了对象状态时,其他线程就会立即看到新缓存的数据。

这样利用final以及volatie,在没有显示地使用锁的情况下仍然保证对象是线程安全的

  • 示例
// 执行因式分解,缓存结果,并通过判断缓存中的数值是否等于请求数值来决定是否直接读取缓存的因式分解结果。
 
public class Demo{
    
    private volatile Cache cache = new Cache(null,null);
    
    public BigInteger[] service(BigInteger param){
        
        BigInteger[] factors = cache.getFactors(param);
        
        if(factors == null){
            factors = factor(param);
            cache = new Cache(param,factors);
        }
    }
}
 
class Cache{
    
    private final BigInteger lastNumber;
    
    private final BigInteger[] lastFactors;
    
    public Cache(BigInteger i,BigInteger[] factors){
        
        lastNumber = i;
        
        lastFactors = factors;
    }
    
    public BigInteger[] getFactors(BigInteger i){
        
        if(lastNumber == null || !lastNumber.equals(i)){
            return null;
        }else{
            return Arrays.copyOf(lastFactors, lastFactors.length);
        }
        
    }
}

 

  • 安全发布

即使某个对象的引用对其他线程是可见的,也并不意味着对象状态对于使用该对象的线程来说一定是可见的

可变对象必须通过安全的方式来发布,意味着在发布和使用该对象时都必须使用同步来确保对象状态呈现一致性。因为对象的初始化无法得到保证,因为除了发布对象的线程外,其他线程可以看到尚未完全创建的对象以及对象包含域的失效值。

而发布不可变对象的引用时,不使用同步仍然可以安全的访问该对象。为了维持这种初始化安全性的保证,必须满足不可变的条件:状态不可修改,所有域都是final类型,以及正确的构造过程。然而如果final类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。

安全发布常用模式:

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

在线程安全容器内部的同步意味着,在将对象放到某个容器,将满足条件4。线程T1将对象A放入一个线程安全的容器,随后线程T2读取这个对象,那么不再需要额外的同步。

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

public static Holeder holder = new Holder(4);

静态初始化器由JVM在类的初始化阶段执行。由于JVN内部存在着同步机制(类初始化后,引用对象已经初始化完成),因此通过这种方式初始化的任何对象都可以被安全发布。

如果对象在发布后不会被修改,那么对于其他在没有额外同步的情况下安全地访问这些对象的线程来说,安全发布是足够的。而对于可变对象,需要在发布时以及在每次对象访问时都使用同步来确保后续修改操作的可见性。

 

#笔记内容来自《java并发编程实战》





以上是关于java多线程2.线程安全之可见性的主要内容,如果未能解决你的问题,请参考以下文章

Java多线程安全可见性和有序性之Volatile

细说Java多线程之内存可见性

高并发多线程安全之信号量线程组守护线程线程栅栏等的分析

多线程之synchronized讲解

java线程-java多线程之可见性

高并发多线程安全之原子性问题CAS机制及问题解决方案