java并发实战阅读笔记 --2

对象的共享

可见性

我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作。但是同步还有另外一个重要的方面,那就是内存可见性,就是在一个线程修改了对象状态后,其他线程能够看到状态的变化。下面是一个关于可见性的例子:

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.print("number");
     }
    }
  }
 public static void main(String[] args) {
  new ReaderThread().start();
  number = 42;
  ready = true;
  }
 }

如果不仔细分析的话,我们可能会认为这个代码块是正确的,首先,我们来分析下这代码想要执行的操作,通过一个线程执行操作,在ready为false的时候,让出自己的cpu时间块,直到ready变为true,才将number的值输出出来。在打开了线程之后,将number置为42,并将ready置为true,想要让程序输出42。可事实并非如此,这段代码可能会有3种结果:
1. 正常输出,读线程对于ready的修改可见,对于number修改也可见。
2. 没有输出,读线程对于ready的修改不可见,一直处于循环中
3. 输出0,读线程对于ready的修改可见,而读的number数值确是初始值。

首先,我们要先了解一下java的内存模型,
1. 所有的变量都存储在主内存中
2. 每个线程都有自己独立的工作内存,里面保存着该线程使用到的变量的副本,也就是主存中变量的拷贝。
3. 线程对于共享变量的读写必须在自己的工作内存中进行,不能直接从主存读写。
4. 不同线程之间无法访问其他线程的工作内存中的变量,变量之间的传递必须通过主存来完成。

所以,基于java的内存模型,线程2对线程1的可见性的实现必须要经过两个步骤:
1. 把工作内存1中修改的变量刷新到主内存中
2. 将主内存读取共享变量的值复制到工作内存2中
所以,必须保证这两点能够完整的执行才能保证可见。

上面输出结果的第二种情况便是对应了读线程对于主线程的修改不可见,才会导致一直处于循环,解决方法就是利用synchronized或者volatile来实现可见性。

重排序

在java中,代码书写的顺序可能与实际执行的顺序不同,在没有同步的情况下,编译器、处理器、以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。重排序包含了以下3种:
1. 编译器优化的重排序
2. 指令并行的重排序
3. 内存系统的重排序

像上面第三个输出结果,输出了0,就意味了这段代码被进行过重排了,因为ready被修改的值已经被读取到了,而number被修改的值却没有,可见编译器将number赋值语句放在了ready赋值语句之后了。在我们编写代码时,不管编译器如何进行重排,我们都需要保证输出的结果是正确的。同样子,只要进行简单的同步,我们也能避免这种情况。

失效数据

上面那个例子展示了在缺乏同步的程序中可能产生错误结果的一种情况:失效数据,即该数据已经过期失效,并不是最新的数据。在我们最经常创建的可变类中,经常会出现这种情况,如下所示:

public class MutableInteger{
 private int value;
 public int get() { return value; }
 public void setValue(int value) { this.value = value; }  
 }

如果一个线程调用了get,此时另外切换到另外一个线程调用set,在这种情况下,我们利用get函数可能获取的就是一个失效的数据。
在这种情况下,必须对get和set都进行同步操作,才能保证不会获取到失效数据。

非原子的64位操作

对于非volatile的long和double类型,jvm允许将64位操作分成两个32位数进行操作,如果读写线程是在两个不同的线程中的话,很可能出现两个不同64位数的高低32位。

加锁的含义不仅仅局限于互斥行为,而且还包括内存的可见性。为了确保可见性,我们必须保证读写操作的线程必须在同一个锁上进行同步。

Volatile变量

volatile可以确保共享数据的可见性,它是通过以下两点来实现的:
1. 当一个变量被声明为volatile类型之后,编译器和运行时都会注意到这个变量是共享的,所有关于重排序的操作都不会在这个变量之上去进行。
2. volatile变量不会被缓存在寄存器或者其他处理器看不到的地方,所以保证了每次修改都能被看到。

volatile有许多优点,它是轻量级的synchronized,利用volatile进行同步时,不会对代码进行加锁,因此不会造成线程的阻塞。但是要用这个volatile变量也有很多限制,volatile的正确使用方式如下:
1. 确保它们自身状态的可见性
2. 确保它们所引用状态的可见性
3. 标识一些重要的程序生命周期事件的发生。

对于第三点,有下面这个例子:

volatile boolean asleep;
...
while(!asleep) {
 contSomeSheep();
 }

这个asleep变量标识了countSomeSheep函数的调用是否能够发生,所以将其标识为volatile变量可以保证每次进入while循环时都能读取到最新的asleep值。

虽然volatile函数有许多优点,但是其语义不能保证递增操作的原子性。加锁操作即可以保证可见性又能保证原子性,而volatile操作只能保证可见性。

当且仅当满足以下条件时,我们才使用volatile:
1. 对变量的写入操作不依赖变量的当前值,或者能保证只有单个操作更新变量值(非原子性)
2. 该变量不会与其它状态一起纳入不变性条件
3. 在访问变量时不需要加锁

发布与逸出

发布:使对象能够在当前作用域之外的代码中使用
逸出:当某个不该发布的对象被发布,就成为逸出

发布对象的最简单方式就是将对象的引用保存在一个共有的静态变量中。如下所示:

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

当我们发布了某个对象时,我们可能会间接的发布其他对象,例如这个set里面包含了其他对象的引用,当我们发布了这个hashset,任何对象都可以便利这个set,拿到里面包含对象的引用并进行操作。
当某个对象逸出后,我们必须假设有某个类或者线程可能会误用该对象,这个也就是我们使用封装的最主要原因,封装可以使得我们对程序的正确性的分析变得可能。
下面是两种错误的逸出操作:

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

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

第一个例子,错误的把states数据给传递出去了,任何对象都可以修改这个数组的内容。
第二个例子,发布了一个内部类的实例,内部类却包含了外部类的引用,即this引用逸出,最常见的错误就是在构造函数中启动了一个线程或者注册监听器,在对象还未完全构造之前,新的线程就能看到它了。为了解决这种问题,我们可以利用工厂方法来解决。如下所示:

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

线程封闭

当访问共享的可变数据时,通常使用同步,一种避免使用同步的方式就是不共享数据。

Ad-hoc线程封闭

这种封闭是指维护线程封闭性的职责完全由程序实现来承担。

栈封闭

这种封闭是指只能通过局部变量才能访问对象,局部变量存储在线程的栈中,每个线程都由自己单独的栈空间,不能相互访问不同线程的栈。

ThreadLocal类

这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal提供了get与set等访问接口的方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新的值。ThreadLoacl类通常用于防止对可变的单实例变量或全局变量进行共享。如下所示:

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

当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免每次执行时都重新分配该临时对象,就可以使用这个技术。

不变性

满足同步需求的另一个方法是使用不可变对象,不可变对象一定是线程安全的,它们只有一种状态,并且状态是由构造函数来控制的,一经赋值就不可改变。满足下面的条件才为不可变对象:
1. 对象创建以后就不能被修改
2. 对象的所有域都是final类型
3. 对象是正确创建的,没有出现this引用逸出

安全发布

我们要确保对象如何被正确的发布,例如下面这个看似正常的例子:

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

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!");
    }
   }
 }

这个对象没有被正确的发布,有可能出现状态不一致的情况,包括了下面两个问题:
1. 除了发布对象的线程外,其他线程看到的holder域是一个失效值,看到的是一个之前的值或者一个null引用。
2. 线程看到的对象引用是最新的,对象里面的值确实失效值。

不可变对象与初始化安全性

任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使发布这些对象时没有使用同步。

安全发布的常用模式

要安全的发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见,一个正确构造的对象可以通过下面的方式来发布:
1. 静态初始化函数中初始化一个对象引用
2. 将对象的引用保存在volatile域或者AtomicReferance对象中
3. 将对象的引用保存到某个正确构造的对象的final类型域中
4. 将对象的引用保存到一个由锁保护的域中

第一个方式,主要是jvm内部存在着同步机制,静态初始化阶段由jvm在类的初始化阶段执行,因此通过这种方式发布的任何对象都能被正确发布。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值