欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

变量可见性和volatile, this逃逸, 不可变对象, 以及安全公开--Java Concurrency In Practice C03读书笔记

程序员文章站 2022-04-21 10:36:14
...

[本文是我对Java Concurrency In Practice第三章的归纳和总结, 也有部分语句摘自周志明所著的"深入理解java虚拟机".  转载请注明作者和出处,  如有谬误, 欢迎在评论中指正. ] 

线程安全包含2个方面: 原子性和可见性, java的同步机制都是围绕这2个方面来确保线程安全的.

 

可见性

理解可见性首先要清楚为什么多线程环境下会有可见性问题. 

现代CPU一般都使用读写速度很快的高速缓存来作为内存和CPU之间的缓冲, 高速缓存的引入可以有效的解决CPU和内存的速度矛盾, 但是也带来了新的问题: 缓存一致性. 在多CPU的系统中, 每个处理器都有自己的高速缓存, 而高速缓存又共享同一内存, 为了解决缓存一致性问题, 需要各个处理器访问缓存时都遵循一定的协议.

另外, 为了获得更好的执行效率, 处理器可能会对代码进行乱序执行优化, 处理器会在计算之后将乱序执行的结果进行重组, 保证该结果与顺序执行的结果是一致的, 但并不保证程序中各个语句计算的顺序与输入代码的顺序一致. java虚拟机在即时编译器中也有类似的指令重排序优化.

java内存模型规定了所有的变量都存储在主内存中, 除此之外每个线程都有自己的工作内存, 线程的工作内存中保存了被该线程使用到的变量的副本拷贝, 线程对变量的所有操作(读取, 赋值等)都必须在工作内存中进行, 而不能直接读写主内存中的变量. 不同的线程之间也无法直接访问对方工作内存中的变量, 线程间变量值的传递均需要通过主内存来完成.

由上可知, 一个线程修改了变量的值, 另一个线程并非总是能够及时获知最新的值, 这就是可见性问题的根源. 例如:

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

由于指令重排序, 主线程中将ready赋值为true的操作可能发生在对number的赋值之前, 因此ReaderThread的输出结果可能为0. 又由于可见性, ReaderThread线程可能无法获知主线程对ready的修改, 那么ReaderThread的循环将不会停止. 也许在特定的机器上, 以上的"异常情况"很难出现, 实际上这取决于处理器架构和JVM实现, 以及运气.

 

synchronized与可见性

synchronized关键字不仅能够保证操作的原子性, 也能保证变量的可见性. JVM规范规定, 如果线程A和线程B通过同一把锁进行同步, 那么线程A在同步代码块中所做的修改对于线程B是可见的.

 

volatile与可见性

java的同步机制除了synchronized之外, 还有volatile.

如果使用volatile关键字修饰一个变量, 该变量就被声明为"易变的". JVM规范规定了任何一个线程修改了volatile变量的值都需要立即将新值更新到主内存中, 任何线程任何时候使用到volatile变量时都需要重新获取主内存的变量值, 而且volatile关键字隐含禁止进行指令重排序优化的语义. 以上的规范保证了volatile变量的线程可见性.

volatile是一种轻量级的同步机制, 不同于synchronized, volatile无法保证操作的原子性, 只能保证变量的可见性. 因此volatile关键字的使用是严格受限的, volatile关键字的正确使用必须同时满足以下条件:

1. 更改不依赖于当前值, 或者能够确保只会在单一线程中修改变量的值. 如果对变量的更改依赖于现有值, 就是一个race condition操作, 需要使用其他同步手段如synchronized将race condition操作转换为原子操作, 而volatile对原子性是无能为力的. 但是如果能够确保只会在单一线程中修改变量的值, 那么除了当前线程外, 其他线程不能更改变量的值, 此时race condition就不可能发生.

2. 变量不需要与其他状态变量共同参与不变约束. 比如start和end变量都被声明为volatile, 并且start和end组成不变约束start<end, 这样的不变约束是存在并发问题的:

private Date start;
private Date end;

public void setInterval(Date newStart, Date newEnd) {
	// 检查start<end是否成立, 在给start赋值之前不变式是有效的
	start = newStart;

	// 但是如果另外的线程在给start赋值之后给end赋值之前时检查start<end, 该不变式是无效的

	end = newEnd;
	// 给end赋值之后start<end不变式重新变为有效
}

volatile变量的典型应用场景是作为标记使用:

public class SocketThread extends Thread {
	public volatile boolean running = true;
	@Override
	public void run() {
		while (running) {
			// ...
		}
	}
}
 

64位数据(long和double类型)

JVM规范允许虚拟机将long和double类型的非volatile数据的读写操作划分为2次32位的操作来进行. 如果多个线程共享一个非volatile的long或double变量, 并且同时对该变量进行读取和修改, 那么某些线程可能会读取到一个既非原值, 也不是其他线程修改值的代表了"半个变量"的数值.

幸好几乎所有平台下的商用虚拟机几乎都选择把64为数据的读写操作作为原子操作来对待, 否则java程序员就需要在用到long和double变量时声明变量为volatile.

 

this逃逸

是指在构造函数返回之前其他线程就持有该对象的引用. 调用尚未构造完全的对象的方法可能引发令人疑惑的错误, 因此应该避免this逃逸的发生.

this逃逸经常发生在构造函数中启动线程或注册监听器时, 如:

public class ThisEscape {
	public ThisEscape() {
		new Thread(new EscapeRunnable()).start();
		// ...
	}
	
	private class EscapeRunnable implements Runnable {
		@Override
		public void run() {
			// 通过ThisEscape.this就可以引用外围类对象, 但是此时外围类对象可能还没有构造完成, 即发生了外围类的this引用的逃逸
		}
	}
}

 在构造函数中创建Thread对象是没有问题的, 但是不要启动Thread. 可以提供一个init方法, 如:

public class ThisEscape {
	private Thread t;
	public ThisEscape() {
		t = new Thread(new EscapeRunnable());
		// ...
	}
	
	public void init() {
		t.start();
	}
	
	private class EscapeRunnable implements Runnable {
		@Override
		public void run() {
			// 通过ThisEscape.this就可以引用外围类对象, 此时可以保证外围类对象已经构造完成
		}
	}
}
  

线程限制

可以通过约定或者java内置的ThreadLocal将对象的访问限制在单一的线程上, 这样一来, 即使对象不是线程安全的, 也不会出现错误.

例如android的GUUI框架规定所有控件的更新都必须发生在主线程里, 因此即使android中的View组件不是线程安全的对象, 我们仍然无需担心会引发并发错误. 如果开发者没有遵循android控件对象的线程限制, 在程序运行时就会抛出异常. 

线程限制的另一个优点是可以防止死锁.

ThreadLocal多用于在线程*享对象.

 

不可变对象

所有并发问题都是由于多个线程同时访问对象的某个可变属性引起的, 如果对象是不可变的, 那么所有的并发问题都将迎刃而解. 

所谓不可变对象是指对象一旦构造完成, 其所有属性就不能更改, 不可变对象显然都是线程安全的. 

对于不可变对象, 需要防止发生this逃逸.

如果需要对多个成员进行一项原子操作, 可以考虑使用这些成员构建一个不可变类. 例如:

public class CashedClass {
	private String cashedStr = "";
	private int cashedHashCode;
	
	public int hashCode(String str) {
		// 如果str是cashedStr, 就直接返回缓存的hashCode值
		if (str.equals(cashedStr)) {
			return cashedHashCode;
		} else {
			// 将cashedStr和hashCode值缓存起来
			cashedStr = str;
			cashedHashCode = cashedStr.hashCode();
			return cashedHashCode;
		}
	}
}

CashedClass不是一个线程安全的类, 因为对cashedStr和cashedHashCode的读写操作不具备原子性, 会发生race condition. 除了使用synchronized进行同步之外, 我们还可以使用不可变对象消除race condition:

public class CashedClass {
	// 使用一个volatile变量持有OneCashedValue对象
	private volatile OneCashedValue oneValue = new OneCashedValue("", 0);

	public int hashCode(String str) {
		int hashCode = oneValue.getStrHashCode(str);
		if (hashCode == -1) {
			hashCode = str.hashCode();
			// 对volatile变量的修改不依赖于当前值, 符合volatile的使用场景
			oneValue = new OneCashedValue(str, hashCode);
		}
		return hashCode;
	}

	/**
	 * 这是一个不可变类
	 */
	public class OneCashedValue {
		// 成员变量都是final的
		private final String str;
		private final int strHashCode;

		// 构造过程中不会发生this逃逸
		public OneCashedValue(String str, int strHashCode) {
			this.str = str;
			this.strHashCode = strHashCode;
		}

		public int getStrHashCode(String str) {
			if (!this.str.equals(str)) {
				// -1表示无效的hashCode值
				return -1;
			}
			return strHashCode;
		}
	}
}
  

公开成员

对象可以通过方法传参, 方法返回值, 非private修饰等方式公开对象的成员, 使得对象之外的代码可以访问相应的成员.

对象的成员一旦公开, 就需要保证多线程环境下所公开成员的可见性, 这就是所谓的安全的公开. 公开一个成员变量的前提是, 该成员变量没有参与任何不变式约束, 且该成员变量没有非法值, 因为一旦公开, 我们无法保证外部修改变量后变量仍然满足不变式约束和未取非法值. 在满足前提的条件下,  可以通过以下方式安全的公开对象的成员:

1. 线程限制. 如果限制对象只可由单一的线程访问, 那么无论公开哪个成员, 都不会产生并发问题.

2. 公开不可变成员. 如果对象的某个成员是不可变的, 那么公开该成员不会产生并发问题.

3. 公开事实上的不可变成员. 如果对象的某个成员是可变的, 但约定访问该成员的所有线程不要去修改这个成员, 那么该成员是事实上不可变的. 这种场景下公开该成员不会产生并发问题.

4. 公开线程安全的成员. 线程安全的成员内部会妥善并发问题, 因此公开线程安全的成员是恰当的.

5. 公开可变的非线程安全的成员. 这就要求所有访问该成员的线程使用特定的锁进行同步.