Java并发编程之对象的共享

Java并发编程实战学习笔记。

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

可见性

可见性是一种复杂的属性,可见性的错误总会违背我们的直觉。当写操作和读操作在不同的线程中执行的时候,我们无法确保执行读操作的线程能适时的看到其他线程写入的值,有时甚至是更本是不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须是有同步机制。

​ 程序Novisibility说明了当前线程在没有同步的情况下共享数据时出现的错误。在代码中,主线程和读线程都访问共享ready和number。主线程启动读线程,然后将number设为42,并将ready设为true。读线程一直循环知道发现ready值变为true,让后输出umber的值。虽然看起来会输出42,但是实际上可能输出0,或者根本就没有输出值无法终止。这是因为在代码中没有使用足够的同步机制,因此无法保证主线程写入的ready值和number值对于读线程来说是可见的。

/**
在没有没有同步的情况之下共享变量(不要这么做)
*/
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;
    }

}

分析是如此 ,这段代码放到编译器中一运行,不管是jdk1.8或是jdk1.6运行后就会发现没有输出值。 这就很奇怪了,相同打的代码得到的值却出现了多种的输出。

Novisibility是一个简单的并发程序,只包含两个线程和两个共享变量,即便如此,在判断程序的执行情况进行判断是十分困难的。

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

失效的数据

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

@NotThreadSafe//类不是线程安全的,如果类未加任何注解,则不能确定是否线程安全,认为是非线程安全的
public class MutableInteger {
    private int value ;

    public int getValue() {
        return value;
    }

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

上面的代码中不是线程安全的,如果在没有同步的情况下,一个线程调用了set,而另一个线程正在调用get方法,可能会看到更新后的值也可能看不到。

接下来对上面的程序做一些修改,通过对get和set等方法进行同步,可以使程序变为一个线程安全的类。仅对set方法进行同步是不够的,调用get的线程任然会看见失效的值

@ThreadSafe//表示类是线程安全的
public class MutableInteger {

    @GuardedBy("this")//表示只有在持有了某个特定的锁时才能访问这个域或方法
    private int value ;

    public synchronized int getValue() {
        return value;
    }

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

非原子的64位操作

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

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

加锁与可见性

内置锁可以确保某个线程以一种可预测的方式来查看另一个线程的执行结果。当线程A执行某个同步代码块时,线程B随后进入由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被释放前,A看到的变量在B获得锁后同样可以由B看到。也就是说,当线程B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块中的所有操作结果。如果没有同步就不能实现以上的保证。

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

volatile变量

​ 变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作重排。(读取volatile变量时总会返回最新写入的值

​ volatile的正确使用方式:确保它们自身状态的可见性。确保它们所引用对象的状态的可见性。标识一些重要的程序生命周期事件的发生(如初始化和关闭)

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

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

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

发布与溢出

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

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

例子:

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

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

下面的例子发布了本应该为私有的状态数据。

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

测试
@org.junit.Test
public  void test(){
    UnSafeStates unSafeStates = new UnSafeStates();
    String arr[] = unSafeStates.getStates();
    arr[0] = "AU";
    System.out.println(unSafeStates.getStates()[0]);
}
输出:
    AU

​ 以上代码通过public访问级别发布了类的域,在类的外部任何线程都可以访问这些域,这样发布对象是不安全的,因为我们无法假设,其他线程不会修改这些域,从而造成类状态的错误。

​ 最后一种发布对象或其内部状态的机制就是发布一个内部的类实例。上面当内部类发布的时候,外部类实例也发布了。

public class Escape{
    private int thisCanBeEscape = 0;
    public Escape(){
        new InnerClass();
    }
    private class InnerClass {
        public InnerClass() {
            //这里可以在Escape对象完成构造前提前引用到Escape的private变量
            System.out.println(Escape.this.thisCanBeEscape);
        }
    }
    public static void main(String[] args) {
        new Escape();
    }
}

如果this引用在构造函数中逸出,那么这种对象就认为是不正确的构造。在对象尚未完全构造之前,新的线程就可以看到它。在构造函数中调用一个可改写的实例方法时(非私有或终结方法),同样也会导致this引用在构造过程中溢出。

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

线程封闭

当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭。当某个对象封闭在一个线程重视,这种将自动实现线程安全。

Ad-hoc线程封闭

指维护线程封闭性的职责完全由程序来承担。

栈封闭

​ 所谓的栈封闭其实就是使用局部变量存放资源,我们知道局部变量在内存中是存放在虚拟机栈中,而栈又是每个线程私有独立的,所以这样可以保证线程的安全。

ThreadLocal类

ThreadLocal和线程Thread的关系图:

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

不变性

​ 满足同步需求的另一种方法就是使用不可变对象。如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。不可变对象一定是线程安全的。

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

  • 对象创建以后它的状态就不能修改
  • 对象的所有域都是final类型
  • 对象是正确创建的(在对象的创建期间,this引用没有逸出)。

Final域

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

为了能够更好的理解我翻阅了书籍(深入理解Java虚拟机)。里面有这样一段话

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理
硬件时提到的主内存名字一样,两者也可以类比,但物理上它仅是虚拟机内存的一部分)。每条线程
还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保
存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内
存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变
量,线程间变量值的传递均需要通过主内存来完成

此处贴上线程、主内存、工作内存三者的交互关系图

posted @ 2021-08-22 09:54  柒间  阅读(155)  评论(0编辑  收藏  举报