编写正确的并发程序,关键问题在于:在访问共享的可变状态时需要进行正确的管理。即
- 避免同一时刻访问相同的数据
- 共享和发布对象
共享和发布即内存可见性:确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化,可以通过显式地同步或者类库中内置地同步来保证对象被安全地发布
可见性
为了确保多个线程之间对内存写入操作地可见性,必须使用同步机制
/**
* @Author jtao
* @Date 2021/2/22 17:08
* @Description
*/
//在没有同步的情况下共享变量(错误)
@NotThreadSafe
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReadThread extends Thread {
@Override
public void run() {
while (!ready) {
Thread.yield();
System.out.println(number);
}
}
}
public static void main(String[] args) {
new ReadThread().start();
number = 42;
ready = true;
}
}
在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。几乎无法对内存操作的执行顺序做出正确的判断
必须要使用同步使得数据在多个线程之间共享
失效数据
如果不使用同步,线程可能会得到一个已经失效的值。
失效数据可能导致意料之外的异常、被破坏的数据结构、不精确的计算以及无限循环等
/**
* @Author jtao
* @Date 2021/2/22 17:25
* @Description
*/
@NotThreadSafe
//非线程安全的可变整数类
public class MutableInteger {
private int value;
public int get() {
return value;
}
public void set(int value) {
this.value = value;
}
}
/**
* @Author jtao
* @Date 2021/2/22 17:28
* @Description
*/
@ThreadSafe
//线程安全的可变整数类
public class SynchronizedInteger {
@GuardedBy("this")
private int value;
public synchronized int get() {
return value;
}
public synchronized void set(int value) {
this.value = value;
}
}
仅对set方法进行同步是不够的,调用get的线程仍然会看见失效值,即读写都要同步
非原子的64位操作
Java内存模型要求读写操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读写操作分解为两个32位的操作。
在多线程程序中使用共享且可变的long和double等类型变量也是不安全的,需要用关键字volatile声明,或者用锁保护起来
加锁与可见性
内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。
线程A、B先后执行某个同步代码块,当线程B执行由锁保护的代码块时,可以得到A在该代码块中所有操作结果。如果没有同步则无法实现。
访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的
加锁的含义不仅仅是互斥,还包括内存可见性。为了确保所有线程都能得到共享变量的最新值,所有执行读写操作的线程都必须在同一个锁上同步
Volatile变量
Volatile变量是一种稍弱的同步机制,用来确保将变量的更新操作通知到其他线程。
当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。
当线程A首先写入一个volatile变量并且线程B随后读取该变量时,在写入volatile变量之前对A可见的所有变量的值,在B读取了volatile变量后,对B
也是可见的。因此,从内存的角度看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块
在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难以理解。仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们
volatile变量和synchronized的不同
volatile变量只能确保可见性,synchronized既可以确保可见性又可以确保原子性
当且仅当满足以下所有条件时,才应该使用volatile变量
- 对变量的写入操作不依赖变量的当前值,或者能确保只有单个线程更新变量的值
- 该变量不会与其他状态变量一起纳入不变性条件中
- 在访问变量时不需要加锁
发布与逸出
发布
“发布(Publish)”一个对象指的是使对象能够在当前作用域之外的代码中使用。
在某些情况下,我们需要发布某个对象,同时要确保线程安全性,则需要同步。
- 发布对象的最简单方法是将对象的引用保存到一个共有的竞态变量中,以便任何类和线程都能看见该对象
/**
* @Author jtao
* @Date 2021/2/22 20:49
* @Description
*/
//通过静态代码块发布一个对象
public class Secret {
public static Set<Secret> knownSecrets;
public void initialize() {
knownSecrets = new HashSet<Secret>();
}
}
逸出
当某个不应该发布的对象被发布时,这种情况被称为逸出。
发布某个对象时,可能会间接地发布其他对象。
/**
* @Author jtao
* @Date 2021/2/22 20:54
* @Description
*/
//使内部的可变状态逸出
public class UnsafeStates {
private String[] states = new String[]{
"AK", "AL"
};
public String[] getStates() {
return states;
}
}
数组state以及逸出了所在的作用域,本应该是私有的变量已经被发布了
当某个对象逸出后,必须假设有某个类或线程可能会误用该对象。这正是需要使用封装的最主要原因:封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变得困难
线程封闭
当访问共享的可变数据时,通常需要使用同步。避免使用同步的方式就是不共享数据。
在单线程内访问数据,就不需要同步。被称为线程封闭。
线程封闭技术的常见应用是JDBC的Connection对象,并且在Connection对象返回之前,连接池不会再将它分配给其他线程,因此该连接管理模式再处理请求时隐含地将Connection对象封闭在线程中。
Java语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和ThreadLocal类
Ad-hoc线程封闭
维护线程封闭的职责完全是由程序实现来承担。
当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个单线程子系统。
由于Ad-hoc线程封闭技术的脆弱性,因此在程序中尽量少用它,在可能的情况下,应该使用更强的县城封闭技术(如栈封闭、ThreadLocal类)
栈封闭
在栈封闭中,只能通过局部变量才能访问对象。局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。
对于基本类型的局部变量,无论如何都不会破坏栈封闭性。
ThreadLocal类
维持线程封闭性的一种更规范方法是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。
ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。
如果需要将一个单线程应用程序移植到多线程环境中,通过将共享的全局变量转换为ThreadLocal对象,可以维持线程安全性
ThreadLocal变量类似于全局变量,可以降低代码的可重用性,并在类之间引入隐含地耦合性,因此在使用时要格外小心。
不变性
满足同步需求的另一种方法是使用不可变对象。如果对象的状态不会改变,那么问题与复杂性也就没有。
如果某个对象在被创建后其状态就不能被修改,那么该对象就被称为不可变对象。
不可变对象一定是线程安全的
不可变对象只有一种状态,且该状态是由构造函数来控制。
- 当满足以下条件时,对象才是不可变的:
1.对象创建以后其状态就不能修改
2.对象的所有域都是final类型
3.对象是正确创建的(在对象的创建期间,this引用没有逸出)
Final域
final域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步
使用volatile类型来发布不可变对象
安全发布
Comments | 0 条评论