【.com原稿】应用开发随着业务量的增加,数据量也越来越大。为了处理海量数据,通常采用多线程的方式来处理数据。图片来自Pexels浅谈Java的多线程编程,线程安全一定不能绕过。线程安全包括原子性、可见性和顺序。今天,我们就来看看它们之间的联系和实现原理。通过线程和竞争条件开发的应用程序将运行在一个进程中,换句话说,进程就是程序的运行实例。运行Java程序的本质就是运行一个Java虚拟机进程。如果一个进程可以包含多个线程,而这些线程将共享进程中的资源。任何一段代码都会在一个线程中运行,也会在多个线程中运行。由线程执行的计算称为任务。为了提高程序的效率,我们会生成多个任务协同工作。这种工作模式可以是并行的。当任务A在执行时,任务B也在执行。如果多个任务(线程)在执行过程中对同一个资源(变量)进行操作,则称这个资源(变量)为共享资源(变量)。当多个线程同时对共享资源进行操作时,例如:写入资源,就会出现竞争条件,导致被操作的资源在不同的时间看到不同的结果。下面看一个多线程访问同一个资源/变量的例子:当线程A和B同时执行Counter对象中的add()方法时,无法知道这两个线程是如何切换的,JVM会按照如下顺序执行代码:从内存中获取this.count的值,放入寄存器中。按值递增寄存器中的值。将寄存器中的值写回内存。当上述操作由线程A和B交错执行时,会出现如下情况:两个线程分别对count变量加2和3,期望的结果是执行完后count的值等于5两个线程。但是两个线程是交错的,虽然两个线程从内存中读取的初始值都是0,然后分别加上2和3,分别写回内存。然而最终的值并不是预期的5,而是最后写回内存的线程(Athread)的值(3)。最后写回到内存的是线程A,所以结果是3,但也有可能是线程B最后写回到内存,所以结果不可知。因此,如果不采用同步机制,线程间的资源/变量的交叉写入将导致不可控的结果。我们将这种计算结果的正确性与时间相关的现象称为竞争条件。线程安全我们前面提到,当多个线程同时写入一个资源/变量时,会出现竞争条件。这种情况的发生会造成最终结果的不确定性。如果把写好的资源看成Java中的一个类,这个类就不是线程安全的。尽管这个类在单线程环境中工作良好,但它在多线程环境中不起作用。例如:ArrayList、HashMap、SimpledateFormat。那么,要实现线程安全,需要考虑以下三个方面,即:原子性、可见性、有序性、原子性、原子性。不会有中间过程。切换到线程执行也是如此。线程中执行的操作要么不执行,要么全部执行。比如在Java中,基本数据类型的读操作是原子操作,看下面的语句:x=10x=x+1第一句是原子操作,因为它直接给x赋值10,而线程在执行这条语句时,直接将10写入内存。第二句包含三个操作,读取x的值,加1,写入新值。这三个操作的组合不是原子操作。Java中的原子包括:lock(锁)unlock(解锁)read(读)load(加载)use(使用)assign(赋值)store(存储)write(写)假设A和B由两个线程一起执行语句2,同时对x变量进行写操作,因为不满足原子操作,得到的结果也是不确定的。在Java中有两种实现原子性的方法。一种是使用锁(Lock)。锁是独占的,在多线程访问时,可以保证一个共享变量在任何时候都可以被一个线程访问,即排除了racecondition的可能性。另一种是利用处理器提供的CAS(Compare-and-Swap)指令实现的,直接在硬件(处理器和内存)层面实现,称为“硬件锁”。可见性说完原子性,我们再来说说可见性。顾名思义,在多线程访问中,一个线程更新共享变量后,后续访问的线程可以立即读取更新的结果。这种情况称为可见性,共享变量的更新对其他线程可见,否则称为不可见。让我们看看Java是如何实现可见性的。首先,假设线程A执行一条指令,该指令运行在CPUA上。这条指令写入的共享变量会存放在CPUA的寄存器中,当这条指令执行时,共享变量会从寄存器写入内存。PS:其实就是通过寄存器到缓存,再到写缓冲区和无序队列,最后写入内存。这里,为了举例,使用了简化。这样做的目的是让另一个CPU中的线程可以访问共享变量。因此,将共享变量写入内存的行为称为“刷新处理器缓存”。也就是说,共享变量从处理器高速缓存刷新到内存中。Flushtheprocessorcache此时,线程B刚好运行在CPUB上,指令需要从内存中的共享变量同步,才能获取共享变量。此缓存同步过程称为“刷新处理器缓存”。也就是说,缓存从内存刷新到处理器的寄存器。经过这两步,CPUB上运行的线程就可以同步到CPUA上的线程处理的共享变量,共享变量的可见性也得到了保证。Refreshprocessorcacheorderness说完可见性,再来说说有序性。Java编译器会调整代码的执行顺序。可以更改两个操作的执行顺序。对于在另一个处理器上执行的多个操作,从其他处理器的角度来看,指令的执行顺序也可能不一致。在Java内存模型中,允许编译器和处理器对指令进行重新排序,但重新排序的过程不会影响单线程程序的执行,但会影响多线程并发执行的正确性。原因是编译器在不影响程序正确性的情况下(单线程程序)出于性能考虑调整源码顺序。在Java中,可以通过Volatile关键字来保证“有序性”。也可以通过Synchronized和Lock来保证顺序。后面会介绍通过内存屏障实现排序。多线程的同步和锁上面提到的线程竞争和线程安全都是围绕着多线程访问共享变量来讨论的。因为这种情况,在做多线程开发的时候就需要解决这个问题。为了保证线程安全,会将多线程的并发访问转换为串行访问。锁(Lock)就是利用这种思想来保证多线程同步的。锁就像共享数据访问的许可证,任何线程在访问共享数据之前都需要获得这个锁。当一个线程获得锁时,其他申请锁的线程需要等待。获得锁的线程会根据线程上的任务执行代码,代码执行完毕后释放锁。在获取锁和释放锁之间执行的代码区域称为临界区,在临界区中访问的数据称为共享数据。线程释放锁后,其他线程就可以获取锁,然后对共享数据进行操作。多线程访问临界区和访问共享数据。上述操作也称为互斥操作。锁就是利用这种互斥操作来保证race的原子性。还记得前面提到的原子性吗?对共享数据的一项或多项操作要么完成,要么未完成,不会出现中间状态。假设临界区中的代码不是原子的。比如上面提到的“x=x+1”,包括三个操作,读x的值,加1,写新值。如果被多个线程访问,根据运行时间的不同,会得到不同的结果。如果给这个操作加上锁,就可以让它成为“原子的”,即一个线程访问它时,其他线程不能访问它。说完锁在多线程开发中的重要性,我们再来看看Java中锁的种类。内部锁内部锁也称为监视器(Monitor),通过Synchronized关键字修改方法和代码块,创建临界区。Synchronized关键字可用于修饰同步方法、同步静态方法、同步实例方法和同步代码块。Synchronized引导的代码块就是上面说的临界区。锁句柄是对对象的引用。比如可以写成this关键字,表示当前对象。锁句柄对应的监视器称为对应同步块的引导锁,相应的我们称对应同步块为锁引导的同步块。内部锁图锁句柄通常是final修饰的(privatefinal)。因为一旦改变了锁句柄,同一个代码块中的多个线程就会使用不同的锁,从而产生竞争条件。同步静态方法等同于当前类是引导锁的同步块。当一个线程执行临界区中的代码时,它必须持有临界区的引导锁。一旦执行了临界区代码,引导临界区的锁就会被释放。内部锁的申请和释放过程由Java虚拟机完成。所以Synchronized实现的锁称为内部锁。因此,不会造成锁泄漏。Java编译器在将代码块编译成字节码时,会处理临界区抛出的异常。Java虚拟机为每一个内部锁分配一个入口集(EntrySet),用来记录等待获取锁的线程。申请锁失败的线程会在入口集中等待机会再次申请锁。当等待的锁被其他线程释放时,入口集合中的等待线程就会被唤醒,从而获得申请锁的机会。内部锁机制将在等待线程中进行选择。选择规则将基于线程活动和优先级。选定的线程将为后续操作持有锁。显示锁是JDK1.5引入的排他锁。它作为一种线程同步机制存在,具有与内锁相同的功能,但它提供了一些内锁所没有的特性。显示锁是java.util.concurrent.locks.Lock接口的实例。实现显式锁的步骤是:创建Lock接口实例申请显式锁Lock访问共享数据在finally中释放锁,避免锁泄漏展示锁使用示例图,可以看出锁支持非公平锁和公平锁。在公平锁中,线程以严格的先进先出(FIFO)顺序获取锁资源。如果有一个“当前线程”需要获取共享变量,则需要对其进行排队。当锁被释放时,它被队列中的第一个线程(Node1)获取,依此类推。公平锁示意图在非公平锁中,当一个线程释放锁时,“当前线程”与等待队列中的第一个线程(Node1)竞争锁资源。通过线程活动和优先级来确定哪个线程持有锁资源。非公平锁图公平锁保证了锁调度的公平性,但是增加了线程挂起和唤醒的可能性,也就是增加了上下文切换的代价。非公平锁加入了竞争机制,性能会更好,可以承载更大的吞吐量。当然,非公平锁使得获取锁的时间更加不确定,可能导致阻塞队列中的线程长期处于饥饿状态。线程同步机制:内存屏障讲到多线程访问共享变量,存在racecondition问题,然后引入锁机制来解决这个问题。上面提到了内部锁和显示锁来解决线程同步的问题,也提到了它解决了race中的“原子性”问题。然后,通过引入内存屏障机制,了解如何实现“可见性”和“有序性”。这就引出了内存屏障的概念。内存屏障被插入到两个CPU指令之间。它用于禁止编译器和处理器重新排序以确保顺序和可见性。为了可见性,我们提到了线程在获取和释放锁时执行的两个操作:“刷新处理器缓存”和“刷新处理器缓存”。前者的动作保证持有锁的线程读取共享变量,后者的动作保证持有锁的线程更新共享变量后,对后续线程可见。另外,为了达到屏障的效果,它还会让处理器在写入和读取值之前先将主存的值写入缓存,并清除无效队列以保证可见性。为了秩序。下面举个例子来说明一下,假设有一组CPU指令:Store表示“存储指令”Load表示“读指令”StoreLoad表示“写-读内存屏障”StoreLoad内存屏障图StoreLoad屏障之前的Store指令不能与StoreLoadbarrier相比,Load指令执行交换,即重新排序。但是StoreLoadbarrier前后的指令是可以互换的,即Store1和Store2可以互换,Load2和Load3可以互换。一般有4种barrier:LoadLoadbarrier:指令序列如:Load1→LoadLoad→Load2。需要保证Load1命令完成后,Load2和后续命令才能执行。StoreStorebarrier:指令顺序如下:Store1→StoreStore→Store2。必须保证Store1指令对其他处理器可见,才能执行Store2及后续指令。LoadStorebarrier:指令序列如:Load1→LoadStore→Store2。在执行Store2及后续指令之前,需要确保Load1指令执行完毕。StoreLoadbarrier:指令顺序如下:Store1→StoreLoad→Load2。必须保证Store1指令对所有处理器可见,才能执行Load2及后续指令。这个内存屏障的开销是四个中最大的(刷新写缓冲区,刷新处理器缓存)。它也是一个通用屏障,具有其他三个内存屏障的功能。一般Java中常用Volatile和Synchronized关键字来实现内存屏障,也可以通过Unsafe来实现。总结从线程的定义和多线程对共享变量的访问开始,就出现了线程资源竞争。竞争条件会导致多个线程访问资源,随着时间的推移,资源结果不可控。这就对我们的多线程编程提出了挑战。因此,我们需要通过原子性、可见性和顺序来解决线程安全问题。共享资源的同步可以解决这些问题,Java提供了内部锁和显式锁作为解决方案的最佳实践。最后介绍了线程同步的底层机制:内存屏障。它通过组织CPU指令的重新排序来解决可见性和排序问题。作者:崔浩简介:十六年开发架构经验。曾在惠普武汉交付中心担任技术专家、需求分析师、项目经理,后在一家初创公司担任技术/产品经理。善于学习,乐于分享。目前专注于技术架构和研发管理。【原创稿件,合作网站转载请注明原作者和出处为.com】
