【并发编程】当我们谈论线程安全时我们在谈论什么

3/8/2017来源:ASP.NET技巧人气:994

线程安全是一个老生长谈的话题,做开发的人人都会碰到且谈论这个话题,今天就来从内存角度上深入剖析一下什么是线程安全。

       首先,我们知道jvm内存总体来讲分为:栈、堆、程序计数器、方法区。其中又分为线程私有(每个线程单独维护)和线程共享的区域,线程私有区域不会涉及多线程间通信和同步问题,所以线程安全肯定是出现在线程共享区域的。

       接下来提出一个问题,上述4个内存区域中,哪些是线程私有的,哪些是线程共享的?我们来一个一个来分析:

栈(虚拟机栈和本地方法栈在这里归为同类讨论)。定义总结:每当启用一个线程时,JVM就为他分配一个java栈,每当线程调用一个java方法时,JVM就会在该线程对应的栈中压入一个帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,当方法执行结束时此栈帧出栈,并将结果返回给对应的操作数栈中。由定义可以知道栈是线程私有的,每个线程拥有一个自己的栈,这个其实在我们平时使用中也可以知道,例如一个线程阻塞时,如果所有线程共用一个栈的话,当前栈帧会一直不能出栈,后续方法都不能执行,则会直接导致整个程序阻塞。 堆。定义总结:堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,我们平时所创建的对象实例绝大部分都存放在堆中。由定义可知堆是线程共享的,所以其中存储的有状态数据要保证线程安全需加密。 程序计数器。定义总结:程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。 方法区。定义总结:方法区是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区很好理解,同样的类,没必要每个线程中都存储一遍其信息,所以肯定是线程共享的。

       看完了枯燥的定义,接下来我们通过代码和内存模型来生动的理解线程为什么以及什么情况下会不安全。

       首先拿我们常用的i++举例,来看如下一段代码:

@Test public 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,内存模型如下:

线程安全1

       由上图可以看出,所以的i++操作其实都是在各自线程栈中计算的,并不会涉及与主线程同步问题,所以此程序是线程安全的。

       接下来我们把上面程序修改如下:

@Test public 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等级:519 thread2最后调用player等级:519

       读者可能会发问了,为什么明明循环加了20次,结果只有519呢?首先我们来看类图:

线程安全2

       通过图可以看到,线程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