《Java 并发编程实战》阅读笔记2系列:对象的安全共享

对象的安全共享

这一节主要研究如何安全的让对象可以被多个线程访问。

可见性

概述

  • **定义:**当一条线程修改了共享变量的值,其他线程可以立即得知这个修改。
  • **实现方式:**在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的方式实现,依赖主内存作为传输媒质。
  • 可以保证可见性的关键字:
    • volatile:通过 volatile 的特殊规则;
    • synchronized:通过“对一个变量执行unlock操作前,必须将该变量同步回主内存”这条规则;
    • final:被 final 修饰的字段,一旦完成了初始化,其他线程就能看到它,并且它也不会再变了;
      • 只要不可变对象被正确的构建出来(没有发生 this 引用溢出),它就是线程安全的。

失去可见性的危害

  • 用来作为状态判断的量被其他线程修改了,运行的那个线程就是看不见……

    public class NoVisibility {
          
          
        private static boolean ready;
        private static int number;
        private static class ReaderThread extends Thread {
          
          
            @Override
            public void run() {
          
          
                while (!ready) {
          
          
                    Thread.yield(); // 放弃当前CPU资源
                }
                System.out.println(number);
            }
        }
        public static void main(String[] args) {
          
          
            new ReaderThread().start();
            number = 42;
            ready = true;
        }
    }
    
    /* 
    这段代码可能有一下三种输出:
    1. 正确输出42
    2. 持续循环下去,如ReaderThread放弃当前CPU资源后,立即再次抢到CPU资源
    3. 输出0,因为在没有同步的情况下,Java编译器,处理器及运行时会对操作顺序进行重排序,
       所以number = 42;和ready = true;这两句的执行顺序可能会互换,
       导致ready为true时,number还没被赋值为42
    */
    
  • 非原子的64位操作:读到的数高位已经被改了,低位还没来得及改

对抗可见性问题的法宝:volatile!

volatile 变量是用来确保将变量的更新操作通知给其他线程的,即**在读取 volatile 变量时,总会返回最新写入的值!**是一种比synchronized 更轻量级的同步机制。

特点

  • 该变量保证对所有线程的可见性
  • 禁止指令重排序优化

两个特点的实现原理!

  • 在 volatile 变量的赋值操作的反编译代码中,在执行了赋值操作之后加了一行:lock addl $0x0,(%esp)
  • 这一句的意思是:给 ESP 寄存器 +0,是一个空操作,重点在 lock 上
  • 首先 lock 的存在相当于一个内存屏障,使得重排序时,不能把后面的指令排在内存屏障之前
  • 同时,lock 指令会将当前 CPU 的 Cache 写入内存,并无效化其他 CPU 的 Cache,相当于对 Cache 中的变量做了一次 store -> write 操作
  • 这使得其他 CPU 可以立即看见 volatile 变量的修改,因为其他 CPU 在读取 volatile 变量前会先从主内存中读取 volatile 变量,即进行一次 read -> load 操作

Java 内存模型中对 volatile 变量定义的特殊规则

在对 volatile 变量执行 read、load、use、assign、store、write操作时:

  • use 操作必须与 load、read 操作同时出现
    • use <- load <- read
  • assign 操作必须与 store、write 操作同时出现
    • assign -> store -> write
  • 同一个线程进行如下两套动作,可以保证:如果 A 先于 B 执行,那么 P 先于 Q 执行
    • 第一套:A (use/assign) -> F (load/store) -> P (read/write)
    • 第二套:B (use/assign) -> G (load/store) -> Q (read/write)

与 synchronized 的区别

  • synchronized:既保证可见性,又保证原子性
  • volatile:只保证可见性(所以count++原子性无法保证)

应用场景:

  • 常见的应用:
    • 某个操作完成的标志
    • 发生中断的标志
  • volatile变量不适用的场景:
    • 运算结果依赖与该变量当前的值,比如i++
    • 运算结果依赖于其他状态变量

通过确保状态不被发布来保证安全性

发布与溢出

发布

使对象能在当前作用域之外使用。

发布方法:
  • 最简单的发布方法:public static
  • 将指向该对象的引用保存到其他代码可以访问的地方
  • 在某个非私有的方法中返回该引用
  • 将引用传递到其他类的方法中

溢出

发布了不该发布的对象。

一个简单的溢出过程
class UnsafeStates {
    
    
    private String[] states = new String[] {
    
    "AB", "CD"};

    public String[] getStates() {
    
    
        return states; // 可以通过这个方法得到states,然后就可以随便修改states,就逸出了
    }
}
this 引用溢出!
  • 产生原因
    • 在一个对象的构造方法中启动了一个线程,并在这个线程的 public 方法中调用了这个对象的方法,相当于将还没构造好的对象的 this 实例泄露了。
  • 解决方法
    • 私有化构造方法,只在构造方法中写新线程的代码但不 start,然后写一个工厂方法 newInstance 来创建实例,在工厂方法中先调用构造函数创建实例,再启动线程,这样就不会把一个还没有构造好的对象发布出去了。

我的理解:我们需要保证别的线程拿到我们的对象时,要是一个完整的执行完构造函数的对象,不能是一个构造函数执行了一半的对象。

线程封闭

栈封闭

使用局部变量,并保证这个局部变量不溢出。

ThreadLocal 类

类似于对应线程的全局变量,但是每一个线程维护一个自己的该变量对应值。

ThreadLocal 实现原理
  • 每一个 ThreadLocal 都有一个唯一的的 ThreadLocalHashCode;
  • 每一个线程中有一个专门保存这个 HashCode 的 Map<ThreadLocalHashCode, 对应变量的值>
  • ThreadLocal#get() 时,实际上是当前线程先拿到这个 ThreadLocal 对象的 ThreadLocalHashCode,然后通过这个 ThreadLocalHashCode 去自己内部的 Map 中去取值。
    • 即每个线程对应的变量不是存储在 ThreadLocal 对象中的,而是存在当前线程对象中的,线程自己保管封存在自己内部的变量,达到线程封闭的目的。
    • 也就是说,ThreadLocal 对象并不负责保存数据,它只是一个访问入口。

不可变对象

定义

  • 对象创建后,其状态不能被修改
  • 对象是正确创建的(无 this 引用逸出)

使用方法

  • 因为对象是不可变的,所以多个线程可以放心大胆的同时访问
  • 当这个对象中的状态需要被改变时,之间废掉当前的对象,new 一个新对象代替现在的旧对象

final 域!

final 域是我们用来构造不可变对象的一个利器,因为被它修饰的域一旦被初始化后,就是不可修改的了,不过这有一个前提,就是如果 final 修饰的是一个对象引用,必须保证这个对象也是不可变对象才行,否则 final 只能保证这个引用一直指向一个固定的对象,但这个对象自己的状态是可以改变的,所以一个所有域都是 final 的对象也不一定是不可变对象

扫描二维码关注公众号,回复: 12441120 查看本文章
2 种初始化方式
final i = 42;
final i;  // 之后在每一个构造函数中给i赋值

注意:对于含有 final 域的对象,JVM 必须保证对象的初始引用在构造函数之后执行,不能乱序执行(也就是说,一旦得到了对象的引用,那么这个对象的 final 域一定是已经完成了初始化的)!

安全发布对象

保证发布的对象的初始化构造过程不会受到任何其他线程干扰,就像加了锁一样,被创建它的线程构造好了,在发布给其他线程。

常用发布模式

  • 在静态块中初始化一个对象引用
  • 将对象引用保存到 volatile 类型的域或者 AtomicReference 对象中
  • 将对象引用保存到某个正确构造的对象的 final 域中
  • 将对象引用保存到一个被锁保护的域中

最简单和安全的发布方式

public static Holder holder = new Holder(42);

可以保证安全的原因:静态变量的赋值操作在加载类的初始化阶段完成,包含在<clinit>()方法的执行过程中,因此这个过程受到 JVM 内部的的同步机制保护,可以用来安全发布对象。

Java 提供的可以安全发布对象的容器

  • Map
    • HashTable
    • ConcurrentMap
    • Collections.SynchronizedMap
      • 使用Collections.synchronizedMap(Map<K,V> m)获得
      • 所有方法都被 synchronized 修饰
  • List
    • Vector
    • CopyOnWriteArrayList
    • CopyOnWriteSet
    • Collections.SynchronizedSet
      • 使用Collections.synchronizedSet(Set<T> s)获得
      • 所有方法都被 synchronized 修饰
  • Queue
    • BlockQueue
    • ConcurrentLinkedQueue

猜你喜欢

转载自blog.csdn.net/weixin_43314519/article/details/113105024