本文转载自微信公众号《飞天小牛》,作者飞天小牛。转载本文请联系飞天小牛公众号。文章标题“千里之外”主要是为了突出本文(狗头)的基础和重要性,而并发编程的知识确实主要是围绕JMM和三大性质展开的。全文上下文如下:1)为什么要学习并发编程?2)为什么需要并发编程?3)介绍Java内存模型4)详细讲解Java内存模型的三大属性(原子性、可见性和顺序),这也是判断线程安全的三个重要指标。以原子性为例,文中大体逻辑如下:什么是原子性?不满足原子性会出现什么问题?如何保证原子性?你想学政治吗?”同样,我们(至少作为学生党)很少去接触它,然后背诵一堆“正确而伟大的废话”,最终成为刻板印象并迅速被遗忘。直到我开始深入挖掘这块知识,而不是一味地死记硬背,我才意识到它是真实的、伟大的,但它不是胡说八道。Java语言和Java虚拟机提供了相当多的并发工具,为我们隐藏了很多线程并发的细节,使得我们在编码的时候可以更加关注业务逻辑,降低了并发编程的门槛。但是无论多么先进的语言、中间件、框架,我们都不应该完全依赖它们来完成所有的并发处理。deas仍然是成为高级程序员的唯一途径。我想上面这段话大概可以回答“我们为什么要学习并发编程?”这个问题。为什么需要并发编程?不知道大家有没有听说过摩尔定律,被誉为计算机第一定律。这是英特尔创始人之一戈登·摩尔经过长期观察总结出来的经验。虽然不是严格推导的真理,但至少目前为止还是有说服力的。通俗地说,它的核心内容就是处理器的性能每两年翻一番。看起来像废话(狗头)。事实上,如今多核CPU的发展速度确实在支持着摩尔定律的有效性。在时代背景下,并发编程已经成为燎原之势。通过并发编程,可以最大限度地发挥多核CPU的计算能力,提高性能。比如在今天的仙境传说的图像处理领域,很多图像处理算法在最初编写代码并调试正确后,还需要一个漫长的优化过程。因为虽然有些算法的处理效果很好,但是如果计算过于耗时,还是无法集成到产品中供用户使用。对于分辨率为1000x800的图像,我们最原始的处理思路是从第一个像素开始,遍历计算到最后一个像素。那么面对如此庞大复杂的计算,为了提高算法的性能,最直接、最简单的思路就是在多线程的基础上充分利用多核CPU的计算能力。整个图像可以分成几个块。比如我们的CPU有8个核心,那么可以分成8块。每张图片的大小为1000*100像素。我们可以创建8个线程,每个线程处理一个图像块。CPU分配一个线程来执行。这样一来,运行速度就会有明显的提升。当然,这样操作之后,运算速度不会提升4倍,因为线程的创建和释放以及上下文切换都有一定的损耗。这里摘自《Java 并发编程的艺术》这本书来回答这个问题,为什么我们需要并发线程?多核CPU时代的到来,打破了单核CPU对多线程性能的限制。多CPU意味着每个线程都可以使用自己的CPU运行,减少了线程上下文切换的开销,但是随着应用系统性能和吞吐量要求的提高,有处理海量数据和请求的需求,有一个迫切需要高并发编程。至于多核CPU盛行的原因,在《深入理解 Java 虚拟机 - 第 3 版》一书中也有涉及。这里我摘录略作修改如下:多任务处理几乎是现代计算机操作系统中必不可少的功能。在很多场景下,让计算机同时做几件事,不仅是因为计算机的计算能力强大,更重要的是计算机的计算速度与其存储和通信子系统的速度之间的差距是太大,以至于CPU不得不花费大量时间等待其他资源,例如磁盘I/O、网络通信或数据库访问。为此,我们必须通过一些手段来“压榨”处理器的运算能力,否则会造成大量的性能浪费,最容易想到的就是让计算机同时处理几个任务,而它也被证明是非常有效的“压榨”手段。另外,一台服务器同时为多个客户端提供服务,除了充分利用计算机处理器的能力之外,是另一种更具体的并发应用场景。受到物理机的启发实际上,物理机遇到的并发问题与虚拟机中的情况有很多相似之处。物理机的并发处理方案对于虚拟机的实现也有相当的参考意义。因此,我们有必要去学习如何处理物理机中出现的问题。上面说了,可以通过并发编程来充分利用CPU资源。其中一个主要原因是计算机的存储设备与CPU的运算速度存在几个数量级的差距,以至于CPU不得不花费大量时间等待其他资源。这是在软件层面,而在硬件层面,现代计算机系统会在内存和CPU之间增加一层或多层缓存,其读写速度尽可能接近CPU的运算速度作为缓冲.将运算需要的数据复制到缓存中,这样运算就可以快速执行,运算完成后会从缓存中同步回内存,这样处理器就不用等待内存读写慢。为此,这不可避免地带来了一个新的问题:缓存一致性(CacheCoherence)。也就是说,当多个CPU的计算任务都涉及同一个主存区域时,各自的缓存数据可能会不一致。如果出现这种情况,同步回主存时应该使用谁的缓存数据呢?为了解决一致性问题,每个CPU在访问缓存时都需要遵循一些协议,按照协议进行读写操作。于是,我们引入了内存模型的概念。在物理机层面,内存模型可以理解为在特定操作协议下对特定内存或缓存进行读写访问的过程抽象。显然,不同架构的物理机可以有不同的内存模型,而Java虚拟机也有自己的内存模型,称为Java内存模型(JMM),其目的是屏蔽各种硬件和操作系统不同的内存访问差异,所以Java程序可以在各种平台上实现一致的内存访问效果。当然,JMM与我们这里介绍的物理机内存模型具有很强的可比性。Java内存模型JMM规定所有变量都存储在主内存(MainMemory)中,每个线程也有自己的工作内存(WorkingMemory)。线程使用的变量的主内存副本存储在线程的工作内存中。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接读写主内存中的数据。这里的主存可以类比前面说的物理机的主存。当然,它实际上只是虚拟机内存的一部分,工作内存可以类比上面说的缓存。《Java 并发编程的艺术》将“工作记忆”称为“局部记忆”。“工作记忆”这本书是这么写的。还有一点,这里的变量其实和我们日常编程中说的变量是不一样的。包括实例字段、静态字段、组成数组对象的元素,但不包括局部变量和方法参数,因为后两者是线程私有的,不会共享,自然不会出现竞争问题。大家都知道就好,不必深究。原子性什么是原子性,类比一台物理机器,它有一个缓存一致性协议来指定主存和缓存之间的操作逻辑。那么JMM中主存和工作存之间有没有特定的交互协议呢?当然!JMM定义了以下8个操作规范,完成了从主存复制一个变量到工作内存,以及从工作内存同步回主存的实现细节。Java虚拟机在实现时,必须保证下面提到的每一个操作都是原子的、不可分割的。8种运算暂且搁置一边,我们来说说什么是原子?原子的本义是“不能再分的最小粒子”,原子运算是指“一个或一系列不能被打断的粒子。举个经典的简单例子,银行转账,A转100元给B。转账操作实际上分为两个离散的步骤:第一步:A账户减去100第二步:B账户增加100我们要求转账操作要原子化,也就是说第1步和第2步是顺序执行的,不能被打断的要么全部执行成功,要么全部执行失败。试想一下,如果转账操作不是原子的会怎样?比如,如果step1执行成功,但是step2没有执行或者执行失败,会导致A账户减少100,B账户没有相应增加100。对于上述情况,原子转账操作应该是如果step2失败,整个转账操作失败,step1回滚,A账户不会减100。OK,了解了原子性的概念之后,我们再来看看JMM定义的8种原子操作。下面的理解就够了,没必要死记硬背:锁(lock):作用于主存的变量,它标识了一个变量独占一个线程的状态。解锁(unlock):作用于主存的变量,它释放一个处于锁定状态的变量,释放的变量可以被其他线程锁定。读(read):作用于主存的变量,它将变量的值从主存传送到线程的工作内存,以供后续的加载动作。加载(load):作用于工作内存的变量,它将读操作从主内存中获取的变量值放入工作内存的变量副本中。use(使用):作用于工作记忆的变量。它将工作内存中的变量值传递给执行引擎。每当虚拟机遇到需要使用变量值的字节码指令时,就会执行这个操作。Assign(赋值):作用于工作内存的变量,将从执行引擎接收到的一个值赋值给工作内存的变量,每当虚拟机遇到为该变量赋值的字节码指令时执行此操作.Store(存储):作用于工作内存的变量,它将工作内存中的一个变量的值传送到主存中,供后续的写操作使用。write(写):作用于主存的变量,它把通过store操作从工作内存中得到的变量的值放到主存的变量中其实对于double和long类型的变量,load,store,read和write操作在一些平台上允许异常,称为“长双非原子约定”,但一般不需要我们特别注意,这里不再赘述。当然,这8个操作不能随便用。为了保证Java程序中的内存访问操作在并发下仍然是线程安全的,JMM规定了执行上述8种基本操作时必须满足的一系列规则。我不会一一列出。之所以多提这个,是因为后面会涉及到一些规则。为了不让大家看的时候一头雾水,还是解释清楚为好。上面我们给出了transfer的例子,那么,在具体的代码中,非原子操作可能会导致哪些问题呢?看下面的代码,不妨考虑一个问题,如果两个线程的初始值为0,一个静态变量自增,一个自减,各做5000次。结果一定是0吗?是个熟悉的问题,我们不能保证这段代码执行结果的确定性(正确性),它可能是正数,也可能是负数,当然也可能是0。那么,我们调用这段代码是线程不安全的,也就是说一段在单线程环境下正常运行的代码,在多线程环境下可能会出现各种意外情况,导致无法得到正确的结果。从线程安全的角度逆向理解线程不安全的概念,可能会更容易理解。这里引用了上面的一句话《Java 并发编程实践》:一段代码被多个线程访问后,仍然可以执行正确的行为。代码是线程安全的。至于这段代码线程之所以不安全,是因为Java中的静态变量自增自减操作不是原子操作,它们实际上都包括三个离散操作:Step1:读取i的当前值Step2:i的值加1(减1)第三步:写回新值可以看出这是一个读-修改-写操作。以i++操作为例,我们看一下它对应的字节码指令:上面代码对应的字节码如下:简单解释一下这些字节码指令的含义:getstatici:获取静态变量i的值iconst_1:准备常量1iadd:自增(自减操作对应isub)putstatici:将修改后的值存入静态变量i如果是单线程环境,先自增5000次,然后自增减5000倍,当然不会有问题。但是在多线程环境下,由于CPU时间片调度,Thread1可能正在执行自增操作,CPU剥夺其资源占用分配给Thread2,即发生线程上下文切换。这样,本应该是连续读、修改、写的动作(连续执行的三个步骤)就可能被打断了。下图中出现的是结果最终是否定的情况:综上所述,如果多个CPU同时对一个共享变量进行读-修改-写操作,那么这个共享变量会同时被多个CPU处理,由于CPU时间片调度等原因,某个线程的read-modify-write操作可能会被其他线程打断,导致操作后共享变量的值与我们的预期不一致。另外,多说一点,除了自增自减,我们常见的i=j操作也是非原子的,分为两个离散的步骤:第一步:读取j的值第二步:改变j的值给i赋值时如何保证原子性那么,如何实现原子操作,即如何保证原子性呢?关于这个问题,其实在处理器层面和Java编程语言层面,都提供了一些有效的措施。比如处理器提供了总线锁和缓存锁,Java提供了锁和循环CAS方法。这里简单介绍一下Java保证原子性的措施。Java内存模型直接保证的原子变量操作包括read、load、assign、use、store、write。我们可以粗略的认为基本数据类型的访问、读写都是原子的(例外是long和double的非原子协议,只要知道就可以了,不需要太在意这些例外情况几乎不会发生)。如果应用场景需要更大范围的原子性保证,Java内存模型也提供了lock和unlock操作来满足这种需求。JVM虽然没有直接向用户开放加锁和解锁操作,但是提供了更高级的字节码指令monitorenter和monitorexit来隐式使用这两个操作。这两条字节码指令在Java代码中体现为一个同步块——synchronized关键字,所以同步块之间的操作也是原子的。除了synchronized关键字等Java语言级别的锁,juc并发包中的java.util.concurrent.locks.Lock接口还提供了一些库级别的锁,如ReentrantLock。另外,随着硬件指令集的发展,在JDK5之后,Java类库中开始使用基于cmpxchg指令的CAS操作(另一个重要的点),由类库中的compareAndSwapInt()和compareAndSwapLong控制提供了sun.misc.Unsafeclass()和其他几个方法包装器。不过在JDK9之前,Unsafe类是不对用户开放的。只能使用Java类库。例如juc包中的整型原子类,其中compareAndSet()、getAndIncrement()等方法使用了Unsafe类的CAS操作。完成。使用这种CAS措施的代码也常被称为无锁编程(Lock-Free)。Visibility什么是可见性回到物理机上,前面说到,由于缓存的引入,必然带来一个新的问题:缓存一致性。同样,这个问题在Java虚拟机中也存在,表现为工作内存和主内存之间的同步延迟,即内存可见性问题。什么是可见性?这意味着当一个线程修改共享变量的值时,其他线程可以立即知道修改。回顾Java内存模型:从上图可以看出,线程A和线程B若要通信,必须经过以下两步:1)线程A将工作内存A中更新的共享变量刷新到主内存中2)线程B去主存中读取线程A之前更新过的共享变量。也就是说,线程A和线程B之间的通信过程必须经过主存。然后,可能会出现问题。举个简单的例子,看下面的代码://线程1执行的代码inti=0;i=1;//线程2执行的代码j=i;当线程1在执行i=1语句时,会先在主存中读取i的初值,然后加载到线程1的工作内存中,然后赋值1,至此,线程1工作内存中的i变为1,但还没有写入主存。如果线程2在线程1即将把i的新值写回主存的时候执行了语句j=i,它就会去主存读取i的值,加载到线程2的工作内存中,而这个当主存中i的值还是0的时候,那么j的值就会是0而不是1,这就是内存可见性的问题。线程1修改了共享变量i的值,但是线程2并不会立即知道这个修改。如何保证可见性你可能会脱口而出使用volatile关键字来修饰共享变量,但除此之外,大家容易忽略的是关键字sunchronized和final也可以保证可见性。正如我上面提到的,为了保证Java程序中的内存访问操作在并发下仍然是线程安全的,JMM规定了一系列在执行8个基本原子操作时必须满足的规则,其中一个就是理论支持synchronized可以保证原子性如下:在对一个变量执行unlock操作之前,必须将该变量同步回主存(执行store、write操作)。也就是说,synchronized修改工作内存中的变量后,在解锁之前,会将工作内存修改的内容刷新到主内存,保证共享变量的值是最新的,这也确保可见性。至于final关键字的可见性,需要结合它的内存语义。这里简单总结一下:一旦final修饰的字段在构造函数中被初始化,而构造函数没有传递this的引用,那么final字段的值就可以在其他线程中看到了。有序性什么是有序性?OK,说完可见性,我们回到实体机上。事实上,除了增加缓存之外,为了让CPU内部的计算单元得到充分利用,CPU可能会对输入的代码进行一些运算。乱序执行优化,CPU会在计算后对乱序执行的结果进行重组,保证结果与顺序执行的结果一致,但不保证每条语句的计算顺序程序中的顺序与输入代码中的顺序一致。因此,如果有一个计算任务依赖于另一个计算任务的中间结果,那么它的顺序就不能通过代码的顺序来保证。同样,Java编译器也有这样一种优化方法:指令重排序(InstructionReorder)。那么,既然可以优化性能,那么是否可以无限制地使用重排序呢?当然不是,CPU和编译器在reordering的时候都需要遵守一个规则,这个规则就是as-if-serial语义:无论怎么reordering,程序在单线程环境下的执行结果都不会改变。为了符合as-if-serial语义,CPU和编译器不会对具有数据依赖性的操作进行重新排序,因为这样的重新排序会改变执行结果。所以在这里,我们引入“数据依赖”的概念。如果两个操作访问同一个变量,并且两个操作之一是写操作,则两个操作之间存在数据依赖性。数据依赖分为三种:写后读、写后写、读后写。见下图以上三种情况。只要将这两个操作的执行顺序重新排序,程序的执行结果就会发生变化。其实在考虑数据依赖的时候,可以通过画图来直观判断。例如:inta=1;//Aintb=2;//Bintsum=a+b;//以上三个操作对C的数据依赖如下图所示:可以看出A和A的关系C、B和C之间存在数据依赖关系,所以在最终执行的指令序列中,C不能重新排序到A或B的前面。但是A和B之间没有数据依赖关系,所以CPU和处理器可以重新排序A和B之间的执行顺序。下面是程序的两个执行顺序:看起来没什么问题,程序的结果重新排序后没有变化,性能有所提升。然而,遗憾的是,我们这里所说的数据依赖只是针对单个CPU中执行的指令序列和单个线程中执行的操作,而不同CPU、不同线程之间的数据依赖并不是CPU定义和考虑的由编译器。这就是为什么我在编写类似串行语义时将“单线程”加粗。看下面这段代码:假设有两个线程A和B,A先执行writer()方法,然后B线程执行reader()方法。当线程B正在执行操作4时,线程A是否可以在操作1中将共享变量a修改为1?答案不一定。由于操作1和操作2没有数据依赖性,CPU和编译器可以重新排序这两个操作;同样,操作3和操作4没有数据依赖性,编译器和处理器也可以对这两个操作进行重新排序。种类。以操作1和操作2的重排序为例,可能会有什么影响?如上图右侧所示,程序执行时,线程A先写入标记变量flag,然后线程B读取这个变量。由于条件的计算结果为真,线程B将读取变量a。此时变量a还没有被线程A写入,所以线程B读到的a的值还是0。也就是说,在这里重新排序破坏了多线程程序的语义。这样,我们可以得出结论,CPU和Java编译器会自发地对指令序列进行重新排序,以优化程序性能。在多线程环境下,由于重排序的存在,可能会导致程序运行结果出现错误。理解了重排序的概念之后,我们可以这样总结Java程序的自然顺序:如果在这个线程中观察,所有的操作都是有序的(简单来说,在线程中是串行的)如果在一个中观察另一个线程中的一个thread,所有的操作都是乱序的(这种乱序主要是指“指令重排序”和“工作内存与主内存同步延迟”的现象)。如何保证有序性Java语言提供了volatile和synchronized来保证线程间的操作顺序。除了保证可见性的语义外,volatile本身还包含了禁止指令重排序的语义,所以它天生就具备保证顺序的能力。synchronized保证顺序的理论支持仍然是由JMM规定的在执行8个基本原子操作时必须满足的一系列规则之一提供的:一个变量只允许同时被一个线程加锁这个规则决定了持有相同锁的两个同步块只能串行输入。不难理解。一般来说,synchronized通过排他锁的方式保证了被synchronized修饰的代码同时在单线程中执行。因此,这满足了似串行语义的一个关键前提,即单线程。这样,在as-if-serial语义的保证下,单线程的有序性也得到保证。Happens-before原则Happens-before是JMM的灵魂。是判断数据是否存在竞争,线程是否安全的一种非常有用的手段。为了知识体系的完整,这里略提一下,后续文章会详细讲解。如果Java内存模型中的所有排序都只通过volatile和synchronized来完成,那么很多操作会变得非常冗长,但是我们在编写Java并发代码时并没有注意到这一点,这要归功于基于“Happens-Before”原则。依靠这个原理,我们可以通过一些简单的规则快速解决并发环境下两个操作是否可能发生冲突的所有问题,而无需陷入Java内存模型的苦涩难懂的定义中。参考《Java 并发编程的艺术》《深入理解 Java 虚拟机 - 第 3 版》
