本文共 3489 字,大约阅读时间需要 11 分钟。
线程安全是一个老生长谈的话题,做开发的人人都会碰到且谈论这个话题,今天就来从内存角度上深入剖析一下什么是线程安全。
首先,我们知道jvm内存总体来讲分为:栈、堆、程序计数器、方法区。其中又分为线程私有(每个线程单独维护)和线程共享的区域,线程私有区域不会涉及多线程间通信和同步问题,所以线程安全肯定是出现在线程共享区域的。
接下来提出一个问题,上述4个内存区域中,哪些是线程私有的,哪些是线程共享的?我们来一个一个来分析:
看完了枯燥的定义,接下来我们通过代码和内存模型来生动的理解线程为什么以及什么情况下会不安全。
首先拿我们常用的i++举例,来看如下一段代码:
@Testpublic void testStackSafe() { Thread thread1 = new Thread(() -> { add2Ten(); }); Thread thread2 = new Thread(() -> { add2Ten(); }); thread1.start(); thread2.start();}private void add2Ten() { int i = 0; while (i < 10) { i++; } System.out.println(i);}
很简单的一段代码,这段代码是否是线程安全的呢?我们来分析下,代码中非原子性的有状态操作其实只有i++,那么这里的i++会否引起线程安全问题呢?这里就取决于i存放在哪里,我们这个例子中的i其实是在线程内部定义的局部变量,所以它存在与线程私有的栈中,即线程1和线程2都有一个int i,内存模型如下:
由上图可以看出,所以的i++操作其实都是在各自线程栈中计算的,并不会涉及与主线程同步问题,所以此程序是线程安全的。
接下来我们把上面程序修改如下:
@Testpublic void testStackSafe() throws InterruptedException { Player player = new Player(); Thread thread1 = new Thread(() -> { add2Ten(player); System.out.println(MessageFormat.format("thread1最后调用player等级:{0}", player.getLevel())); }); Thread thread2 = new Thread(() -> { add2Ten(player); System.out.println(MessageFormat.format("thread2最后调用player等级:{0}", player.getLevel())); }); thread1.start(); thread2.start(); Thread.sleep(1000l);}private void add2Ten(Player player) { for (int i = 0 ; i < 10 ; i++){ player.levelUp(); }}class Player { private int level = 500; public void levelUp() { level++; } public int getLevel() { return level; }}
读者可以多次运行上面程序,会发现有时候会输出如下结果:
thread1最后调用player等级:519thread2最后调用player等级:519
读者可能会发问了,为什么明明循环加了20次,结果只有519呢?首先我们来看类图:
通过图可以看到,线程1和线程2在执行player.levelUp()时,需要先通过player的引用取到int level,之后在栈中做++运算,再将运算结果同步回去。这里引入线程同步问题原因概念:线程计算的时候,原始的数据来自内存,在计算过程中,有些数据可能被频繁读取,这些数据被存储在寄存器和高速缓存中,当线程计算完后,这些缓存的数据在适当的时候应该写回内存.JMM规定了jvm有主内存(Main Memory)和工作内存(Working Memory) ,主内存存放程序中所有的类实例、静态数据等变量(ps:其实就是我们说的堆、方法区),是多个线程共享的。而工作内存存放的是该线程从主内存中拷贝过来的变量以及访问方法所取得的局部变量(ps:其实就是栈),是每个线程私有的其他线程不能访问,每个线程对变量的操作都是以先从主内存将其拷贝到工作内存再对其进行操作的方式进行
.我们看出,这一步时,就会出现线程不安全,假如线程1取到level为1,之后在线程1的栈中做运算++后为2此时还未同步回堆中,线程2读取的还是1,之后拷贝回自己的栈中计算出结果也是2,这时明明执行了两步计算,结果却只增加了1。这就是上面例子有时会输出小于520这个值的原因。
通过上面可以看出线程不安全就是起于主存和缓存间同步的原因,那么我们要解决安全问题可以从哪些方面着手呢?
1. 可见性。如上述例子,我们之所以出现线程不安全就是因为线程1在自己的栈中做计算时,线程2是不知道这一动作的,如果让这一系列动作变得可见则线程2可以实时看见线程1的值的话就不会出现上述问题。java内置了volatile关键字用于实现此功能:volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型变量时总会返回最新写入的值。 2. 原子性。同样上述例子中,假如我们线程1在读取-拷贝值-计算-回写值这一整个过程中,线程2并没有进入,而是在等待线程1执行完毕,即把线程1的这一系列操作当做一个原子操作来看的话,线程2就不能插入到其中步骤,它进入的时机只能是读取前或者写值后,这样的话线程1中不论做多久操作,线程2的计算结果都不会收到影响。java内置synchronized关键字来实现加锁,及可将其锁定的代码块当做具备原子性的操作处理。此处volatile和synchronize就不用代码演示了,本篇主要为了讲述原理,不在于实际运用。
总结:
欢迎关注个人博客:blog.scarlettbai.com