Java线程可见性与原子性

有时候用户希望能够根据需要结束当前的任务,比如某个线程正在下载一个特别大的文件,已经花费了很长时间还是没有下载完成,用户希望取消整个下载操作;可以设置一个停止变量,线程在现在文件期间每读取1M的数据就检查一下停止变量的值,如果停止变量值被用户设置为true,那么就直接从下载任务中退出,否则继续执行下载任务。

public class ThreadTest {
	private static boolean stop = false;  // 普通stop共享变量
	public static void main(String[] args) throws Exception {
		new DownloadThread().start(); // 开启下载线程
		ThreadUtils.sleep(1000);
		new UserThread().start(); // 开启用户线程
	}

	static class DownloadThread extends Thread {
		@Override
		public void run() {
			String name = Thread.currentThread().getName();
			System.out.println(name + " Start Download....");
			while (!stop) {
				// 下载文件
			}
			System.out.println(name + " " + System.currentTimeMillis() + ": stop = " + stop);
			System.out.println("Stop Download....");
		}
	}

	static class UserThread extends Thread {
		@Override
		public void run() {
			String name = Thread.currentThread().getName();
			stop = true;
			System.out.println(name + " " + System.currentTimeMillis() + " stop = " + stop);
		}
	}
}

上面的示例新建了线程DownloadThread负责模拟下载操作,在开始运行时会打印开始下载,之后使用while(!stop)循环模拟下载,可以看出如果stop被设置为true,那么就应该退出while循环打印出停止下载文字。UserThread会在执行时设置stop=true,在main函数中先开启下载线程,过了一秒后开启用户线程,但是下载线程并没有从while循环退出,没有打印停止下载文本。

Thread-0 Start Download....
Thread-1 1554388048732 stop = true

修改一下stop变量的声明加一个volatile关键字,重新运行上面的代码,会发现在用户线程设置stop=true之后下载线程几乎同时退出了while循环并且打印出了停止下载文本。

private static volatile boolean stop = false; // 设置共享变量stop为volatile
 // 运行结果
Thread-0 Start Download....
Thread-1 1554388437338 stop = true
Thread-0 1554388437338: stop = true
Stop Download....

从运行结果看出不加volatile关键字时在用户线程修改了stop变量下载线程无法及时感知到,加了volatile下载线程立即就感知到stop变量的值发生了变化。在JVM实现的线程内存模型中像stop这样可以被多个线程同时访问的变量被称作共享变量,共享变量在内存中并不只有一份,而是有多分拷贝同时存在,下载线程无法及时感知到stop变量在其他线程被修改,这就是共享变量多线程可见性的问题,接下来就讨论Java中共享变量可见性出现问题的原因和解决方法。

内存可见性

计算机的存储结构按照存储速度区分主要分成:外部存储,比如磁盘,通常使用机械运动的方式查找数据,速度最慢,但是它的容量是最大的;内存存储存取的速度较外部存储快,容量相对较小,但是和CPU的运行速度相比要慢的多,如果CPU每次运行都从内存读取数据在执行会有大量的时间处于等待状态,降低了CPU的处理效率,为此计算机又引入了CPU高速缓存;高速缓存容量较内存更小但是存取速度更快,几乎能够和CPU的执行速度相匹配,高速缓存只是用来存储使用比较频繁的内存数据,CPU直接操作的数据还需要放到内部寄存器中;寄存器容量比高速缓存更小,但是存取速度最快。CPU在读取数据的时候会先从内存读取,读取到的数据会放到高速缓存中,再放到寄存器中做运算操作,运算完成后会将寄存器中的新值先拷贝到高速缓存中再从高速缓存拷贝到内存中。需要注意的是这些拷贝操作并不保证立即被执行,可能会有延迟更新的情况。
在这里插入图片描述
在JVM虚拟机内存模型中会将内存映射为所有线程都可以访问的主内存,而高速缓存和寄存器则被映射成为只有当前线程可以访问的工作内存。共享变量最初只在主内存中存在,当线程需要访问共享变量的时候会从主内存将变量拷贝到自己的工作内存中,之后线程对共享变量的操作都只是针对工作内存拷贝的操作,至于何时会将工作内存中修改的共享变量更新到主内存中则没有明确规定,同样内存中的变量发生变化何时同步到工作内存中也没有明确的规定。
 JVM内存模型与存储体系映射

以上面的下载模拟为例,stop变量是一个存在于主内存中的共享变量,下载线程想要读取stop共享变量的内容就需要先从主内存中将其拷贝到下载线程的工作内存中,之后所有的读取操作都只是对工作内存中的拷贝做读操作;用户线程想要修改共享变量stop,也会首先将stop变量拷贝到自己的工作内存中,stop=true修改的其实是用户线程工作内存中里的拷贝;下载线程不断的读取自己工作内存的stop会发现根本没有发生变化,也就不会停止while下载循环。用户线程的stop拷贝何时会被更新到主内存中的stop变量,主内存中stop变量新值何时会被更新到下载线程的stop拷贝,这些都是不确定的,不同的JVM虚拟机会采用不同的更新策略。
volatile变量写入读取
后面修改stop变量为volatile类型从运行结果上看用户线程更新自己拷贝后,下载线程立即就读取到了新的变量值。JVM虚拟机保证所有被声明了volatile的变量在工作内存被写操作后会立即更新到主内存中,而任何对volatile变量的读取操作都会从主内存中读取,如果工作内存存在共享变量的拷贝那么会先删除该拷贝,之后再从主内存中读取。

stop共享变量在用户线程被更新后JVM会立即将用户线程工作内存的stop拷贝更新到主内存的stop变量里,之后下载线程读取stop变量时会先删除其工作内存中的是stop拷贝,再从主内存中读取stop变量并且放到自己的工作内存中。像这种在一个线程中更新的共享变量能够及时被其他线程看到就是内存可见性,JVM虚拟机中声明共享变量为volatile类型就能够保证变量的内存可见性。

操作原子性

多线程中的原子操作通常是指不会被线程调度打断,这种操作一旦开始就一直执行到结束,中间不会发生任何线程切换。在JVM规范中规定,JVM的每条指令都是原子操作;但是读取或写入long或double类型的变量其实还不是原子操作,它们都是64位的数据,每次读取和写入时都会分为两次,每次读取或写入32位数据。为了更好理解原子操作,请看示例。

public class AtomicTest {
	private static volatile int myBalance = 0;
	
	public static void main(String[] args) throws Exception {
		for (int i = 0; i < 100; i++) {
			new IncrementThread().start();
		}
	}

	static class IncrementThread extends Thread {
		@Override
		public void run() {
			for(int i = 0; i < 200; i++) {
				String name = Thread.currentThread().getName();
				myBalance++;  // 执行自增操作
				System.out.println(name + " " + System.currentTimeMillis() + 
": myBalance = " + myBalance);
			}
		}
	}
}

上例中创建了100个子线程,每个线程都会对myBalance共享变量做200次自增操作,按照正常的逻辑最终的myBalance结果应该是100 * 200也就是20000,然而实际执行的结果有时候是20000,有时候又会是19998等比20000小的数字。查看myBalance共享变量的声明,其中有volatile关键字也就是说如果myBalance变量的值发生改变其他的线程能够立即看到新值,自然就不存在可见性问题导致更新丢失。

在单CPU系统中多线程的执行其实是并发执行,CPU在微观层面上实际只能一次执行一条指令,由于CPU的执行速度非常的快,通过不断切换执行不同线程的指令达到宏观上看起来多线程共同执行的效果。操作系统通常采用时间片轮转的方式来实现多线程调度,每个线程都有自己的执行时间片,等时间片用完就会被切换给别的线程继续执行,时间片切换线程时被中断位置是不确定的。除了时间片轮转切换,有时候有些线程的优先级更高也会抢占低优先级线程的CPU资源导致低优先级线程执行中断。

Java中自增操作并不是单一的指令,而是有三个步骤实现的,首先是读取变量,接着做加一操作,最后执行写入操作,在线程切换中可能会在这三个操作任何一个之后发生。注意tmp变量时一个临时变量,它只存在于线程的本地内存中,并不存在于主内存中,只有定义它的线程可以访问。

int tmp = myBalance;  // 读取变量,原子操作
tmp = tmp + 1;  // 加法操作变量,原子操作
myBalance = tmp; // 写入变量,原子操作

现在假定一种CPU实际的指令执行序列,按照这种顺序执行的多线程就会导致myBalance的更新操作出现增加数值操作丢失,最后计算的总体结果出现错误。
在这里插入图片描述
从上面的示例可以看出导致更新增加丢失的主要原因在于整个自增操作不是原子操作,在Thread-1读取到myBalance后做了加一操作,但是还未写入myBalance就被Thread-2线程抢占CPU,之后Thread-2线程自增成功,但是Thread-1写入myBalance的时候依然用的是旧的myBalance计算结果,此次自增操作其实没有为myBalance加上一。

像自增操作这类由多个原子操作指令组成的逻辑很可能会碰上线程切换,Java中可以采用锁机制来防止多线程切换导致数据不一致的问题。Java里的synchronized关键字可以提供简单的锁同步机制,synchronized关键字可以用在三种代码块里:静态方法、成员方法和synchronized代码块。

public static synchronized void add(int x, int y); // 静态方法
public synchronized void add(Object obj); // 实例方法
synchronized(obj) {  }  // synchronized代码块

在静态方法中synchronized锁定的对象是类的class对象,成员方法里锁定的对象就是该成员方法所在对象this,而synchronized代码块里锁定的对象就是代码块指定的对象,被锁定的对象也通常被称作监视器对象。synchronized除了能够提供锁同步机制,还能够支持内存可见性,在线程获取锁对象的时候会先清空自己工作内存里的数据,之后直接从主内存中读取所有的共享数据;在线程释放锁对象的时候会将工作内存中做了修改的共享变量更新到主内存中去;因而在使用synchronized锁同步的地方就可以不再使用volatile关键字来保证内存可见性了,直接使用普通的变量即可。

public class AtomicTest {
	private static int myBalance = 0;
	private static Object lock = new Object();
	
	public static void main(String[] args) throws Exception {
		for (int i = 0; i < 100; i++) {
			new IncrementThread().start();
		}
	}

	static class IncrementThread extends Thread {
		@Override
		public void run() {
			for(int i = 0; i < 200; i++) {
				synchronized(lock) { // 执行自增时需先获得锁
					String name = Thread.currentThread().getName();
					myBalance++;
					System.out.println(name + " " + System.currentTimeMillis() 
+ ": myBalance = " + myBalance);
				}
			}
		}
	}
}

上例中在多个线程共享同一个lock锁对象,当某个线程想要自增myBalance共享变量时必须先获取锁对象,其他线程因为锁对象被占用无法进入自增操作代码,需要注意在使用锁保证互斥操作时需要保证所有的线程锁定的是同一个锁对象。使用锁同步机制后不管如何运行最终的运行结果都是20000,不会再出现自增值丢失的情况了。现在来分析一下有了锁操作是如何保证自增操作不丢失的。
在这里插入图片描述
可以看到由于锁对象的存在切换线程后Thread-2线程由于无法获取锁对象导致后面的代码无法继续执行,只能等到切换线程,之后Thread-1在完成了myBalance的写入操作后才释放锁,Thread-2线程之后才能成功获取锁对象并且执行自增操作。自增操作虽然不是原子操作,加上synchronized锁之后它依然会被线程切换打断,但是别的线程由于没有获取锁只能等待,自增操作就具有了原子性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值