Fork me on GitHub

高效并发

注意:所有文章除特别说明外,转载请注明出处.

高效并发

[TOC]

第十二章 Java内存模型与线程

多任务处理的原因是因为CPU运算速度与计算机的存储、通信子系统的差距太大,大量的时间都花费在磁盘IO、网络通信以及数据库的访问上面。

衡量一个服务性能的高低好坏,每秒事务处理数(TPS)是最重要的指标之一,代表每秒内服务端平均能响应的请求总数。

12.3 Java内存模型

Java虚拟机规范中试图定义一种Java内存模型(JMM)来屏蔽掉各种硬件和操作系统的内存访问的差异性,从而实现让Java程序在各种平台下都能达到一致的内存访问效果。

12.3.1 主内存与工作内存

Java内存模型的主要目标就是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量的底层细节。

12.3.2 内存间交互操作

关于主内存与工作内存之间具体的交互协议,一个变量如何从主内存拷贝到工作内存,相反操作等,Java内存模型定义8种操作完成。

1.lock(锁定):作用于主内存变量。
2.unlock(解锁):作用于主内存变量。
3.read(读取):作用于主内存。
4.load(载入):作用于工作内存变量。
...

12.3.3 对于volatile型变量的特殊规则

关键字volatile是Java虚拟机提供的最轻量级的同步机制,但是很多情况下程序员使用synchronized来进行同步。因为不理解的缘故。

当一个变量定义为volatile之后,其具备两种特性:

1.保证此变量对所有线程的可见性,可见性表示当一个线程修改变量值的时候,其它线程立刻得知修改后的新值。

提示:普通变量不能做到上面的,它需要通过主内存来完成。如果线程A修改一个普通变量的值,然后主内存进行回写,另外一个线程B在A完成回写之后再从主内存进行读取操作,新的变量才会对线程B可见。

提示:JMM实现让volatile修饰变量让其它内存立刻可见的思路在于:==当写一个volatile变量时,JMM会将该线程对应的工作内存中的共享变量值刷新到主内存中==,==当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量==。volatile变量正是通过这种写-读方式实现对其他线程可见(但其内存语义实现则是通过内存屏障)。

注意:volatitle变量在各个线程的工作内存中不存在一致性问题。Java里面运算不是原子操作,==导致volatitle变量的运算在并发下一样是不安全的==。

2.volatile禁止指令重排优化,由于 编译器和处理器 都能执行指令重排优化。普通变量仅仅能保证该方法在执行过程中得到正确的结果,但是不能保证程序代码的执行顺序。

如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。

内存屏障(内存栅栏):是一个CPU指令,作用有两个:1.保证特定操作的执行顺序。2.保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。

提示:使用volatile的优势:在某些情况下,volatile同步机制的性能要优于锁(synchronized关键字),但是由于虚拟机对锁实行的许多消除和优化,所以并不是很快。volatile变量读操作的性能消耗与普通变量几乎没有差别,但是写操作则可能慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

提示:指令重排序,指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应的电路单元处理,但并不是说指令任意重排,CPU需要能正确处理指令依赖情况以保证程序能得出正确的执行结果。

12.3.4 对于long和double型变量的特殊规则

因为Java内存模型要求lock unlock read load assign use store write这8个操作都具有原子性,但是对于64位的数据类型(long和double)在模型中特定一条宽松的规定:允许虚拟机将没有被volatile修饰的 64位 数据的读写操作划分为 两次32位 的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这4个操作的原子性,这点就是所谓的long和double的非原子性协定。

因为Java内存模型虽然允许不将long和double变量的读写实现成原子操作,但是允许虚拟机选择将这些操作实现为具有原子性的操作。

12.3.5 原子性、可见性与有序性

1.原子性:由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write;在synchronized块之间的操作也具备原子性。

2.可见性:是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改;除了volatile之外,Java还有synchronized和final关键字能实现可见性。

3.有序性:如果在本线程内观察,所有的操作都是有序的(线程内表现为串行的语义)。如果在一个线程中观察另一个线程,所有的操作都是无序的(指令重排序现象和工作内存与主内存同步延迟)。Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性。

12.3.6 先行发生原则

先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,影响包括了修改了内存中共享变量的值、发送了消息、调用了方法等。

面是Java内存模型下一些天然的先行发生关系:

程序次序规则

管程锁定规则

volatile变量规则

线程启动规则

线程终止规则

线程中断规则

对象终结规则

传递性

时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。

12.4 Java与线程

12.4.1 线程的实现

线程是比进程更轻量级的调度执行单位,线程的引入可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源又可以独立调度。Thread类与大部分的Java API有显著的差别,它的所有关键方法都是声明为Native的。

实现线程有三种方式:

1.使用内核线程实现(系统调用代价相对较高、一个系统支持轻量级进程的数量是有限的)。

2.使用用户线程实现(优势在于不需要系统内核支援,劣势在于所有线程操作都需要用户程序自己处理)。

3.使用用户线程加轻量级进程混合实现(用户线程是完全建立在用户空间中,因此用户线程的创建、切换等操作依然廉价,并且可以支持大规模的用户线程并发;而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。)

12.4.2 Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度和抢占式线程调度。

1.协同式调度多线程系统:线程的执行时间由线程本身来控制,线程执行完之后要通过通知系统切换到另一个线程上。

    优势:实现简单。

2.抢占式线程调度:线程由系统来分配执行时间,线程的切换不由线程本身来决定。

Java线程优先级

...

12.4.3 状态转换

Java语言定义了五种线程状态,在任意一个时间点,一个线程只能有且只有其中一种状态,分别是新建(New)、运行(Runnable)、无限期等待(Waiting)、限期等待(Timed Waiting)、阻塞(Blocled)、结束(Terminated)。

第十三章 线程安全与锁优化

13.1 概述

13.2 线程安全

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。

13.2.1 Java语言中的线程安全

我们可以将Java语言中各种操作共享的数据分为5类:

1.不可变:不可变带来的安全性是最简单和最纯粹的,如final的基本数据类型。如果共享的数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行,比如:String类的substring、replace方法。Number类型的大部分子类都符合不可变要求的类型,但是AtomicInteger和AtomicLong则并非不可变的。

2.绝对线程安全:Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。比如java.util.Vector,不意味着调用它的是时候永远都不再需要同步手段了。

3.相对线程安全:通常意义上所讲的线程安全就是相对相对线程安全,其需要保证对这个对象单独的操作时线程安全的,我们在调用的时候不需要额外的保障措施,但对于特定顺序的连续调用需要额外的同步手段保证调用的正确性。

4.线程兼容:指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用;我们说一个类不是线程安全的,绝大多数时候指的是这一种情况。

5.线程对立:无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码,Java语言中很少出现。

    5.1 线程对立的例子:

        Thread类的suspend()方法和resume()方法,如果有两个线程同时持有一个线程对象,一个尝试中断线程,另一个尝试恢复线程,如果并发进行的话无论调用是否进行同步,目标线程都会有死锁的风险。

13.2.2 线程安全的实现方法

虚拟机提供的同步和锁机制在线程安全中实现起到重要的作用。

1.互斥同步(阻塞同步)

    同步:表示在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个或一些线程使用。

    互斥:是实现同步的一种手段,临界区 | 互斥量 | 信号量都是主要的互斥实现方法。

提示:在Java中最基本的互斥同步手段就是 synchronized 关键字(该关键字经过编译之后生成了monitorenter和monitorexit两个字节码指令)。

除了synchronized关键字之外,我们还能使用java.util.concurrent包的重入锁(ReentrantLock)来实现同步,在基本用法中,synchronized关键字和ReentrantLock很像。相比synchronized关键字ReentrantLock增加了一些高级功能:等待可中断、可实现公平锁以及锁可以绑定多个条件。

互斥同步属于一种悲观的并发策略,认为如果不做正确的同步措施就会发生问题,所以无论共享数据是否真的会出现竞争,其都要加锁。

2.非阻塞同步

    互斥同步最主要的问题就是进行线程阻塞和唤醒带来的性能问题,其属于一种悲观的并发策略。

    随着硬件指令集的发展,我们有了另外一个选择即基于冲突检测的乐观并发策略,就是先进行操作,如果没有其他线程争用共享数据那就操作成功了,如果有争用产生了冲突,那就再采取其他的补偿措施(最常见的就是不断重试直至成功),这种同步操作称为非阻塞同步。

    Java并发包的整数原子类,其中的compareAndSet和getAndIncrement等方法都使用了Unsafe类的CAS操作。

3.无同步方案

    要保证线程安全,并不是一定就要进行同步;有一些代码天生就是线程安全的,比如可重入代码和线程本地存储的代码。

13.3 锁优化

13.3.1 自旋锁和自适应自旋

互斥同步对性能最大的影响是阻塞的实现,挂起线程 和 恢复线程 的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。

另外在共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得,如果让两个或以上的线程同时并行执行,让后面请求锁的那个线程稍等一下,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。

提示:为了让线程等待,我们只需让线程执行一个忙循环,这些技术就是所谓的自旋锁。

在JDK 1.6已经默认开启自旋锁;如果锁被占用的时间很短自旋等待的效果就会非常好,反之则会白白消耗处理器资源。在JDK 1.6中引入了自适应的自旋锁,这意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

13.3.2 锁消除

锁清除表示虚拟机 即时编译器 在运行时对一些程序要求同步,但是被检测到不可能存在共享数据竞争的锁进行清除。

提示:锁清除的判断依据来源于 逃逸分析 的数据支持(如果判断一段代码中,堆上的所有数据都不会逃逸出去从而被其它线程访问到,那就可以将它们当做栈上的数据处理,认为它们是线程私有化的,同步加锁自然无需进行)。

13.3.3 锁粗化

原则上总是推荐将同步块的作用范围限制得尽量小,只有在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。

但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

13.3.4 轻量级锁

它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

HotSpot虚拟机的对象头分为两部分信息

1.第1部分用于存储对象自身的运行时数据(如哈希码、GC分代年龄等),这部分称为mark word,是实现轻量级锁和偏向锁的关键。

2.第2部分用于存储指定方法区对象类型数据的指针。如果对象是数组,还会有一个部分存储数据的长度。

13.3.5 偏向锁

偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能(在没有实际竞争的情况下,还能够针对部分场景继续优化)。

如果不仅仅没有竞争,自始至终使用锁的线程都只有一个,那么维护轻量级锁都是浪费的。偏向锁的目的是:减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗,轻量级锁每次申请 | 释放锁都至少需要一次 CAS,但是偏向锁只有初始化时需要使用一次CAS。

如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争情况下将整个同步都消除掉,连CAS操作都不做。

偏向锁表示会偏向第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其它线程获取,那持有偏向锁的线程将永远不需要再进行同步。

本文标题:高效并发

文章作者:Bangjin-Hu

发布时间:2019年10月15日 - 09:22:26

最后更新:2020年03月30日 - 08:13:19

原始链接:http://bangjinhu.github.io/undefined/高效并发/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

Bangjin-Hu wechat
欢迎扫码关注微信公众号,订阅我的微信公众号.
坚持原创技术分享,您的支持是我创作的动力.