Java并发编程的艺术

本文深入探讨了多线程的效率问题,指出并发执行速度有时会因线程创建和上下文切换开销而变慢。介绍了减少上下文切换的方法,如无锁并发编程、CAS算法和协程。同时,详细阐述了死锁的避免策略、volatile的内存语义、锁的实现原理以及JMM的happens-before规则。此外,还讨论了final域的内存语义和其对重排序的约束,确保正确同步的多线程程序的执行结果不变。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

多线程一定快吗?

  • CPU通过时间片分配算法循环执行任务,当前任务执行一个时间片后,会切换到下一个任务。但是在切换前会保存上一个任务的状态。以便下次切换回这个任务时可以再加载这个任务的状态,所以任务从保存到再加载的过程就是一次上下文切换。
  • 为什么并发执行的速度,有时候会比串行慢,是因为线程有创建和上下文切换的开销。

如何减少上下文的切换?

  • 无锁并发编程。多线程竞争锁,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照哈希算法取模分段,不同线程处理不同段的数据。
  • CAS算法。Java中的Atomic包使用CAS算法来更新数据而不需要加锁。
  • 使用最少线程。如果任务很少,但是创建了很多线程来处理,会造成大量线程都处于等待状态。
  • 协程。在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

如何避免死锁?
1.避免一个线程同时获取多个锁。
2.避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
3.尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
4.对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
Volatile
volatile是轻量级的synchronized,他在多处理器开发中保证了共享变量的可见性。

  • 可见性的意思是,当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果一个字段被声明为volitile,Java线程内存模型确保所有线程看到这个变量的值是一致的。volatile比syschronized执行成本更低,因为它不会引起线程上下文的切换和调度。

有volatile变量修饰的共享变量进行写操作的时候会多出一个Lock前缀的指令,该指令在多核处理器下会发生两件事情(p9):

  • 将当前处理器缓存行的数据写回到内存(通过LOCK#信号锁总线和锁缓存+缓存一致性两种方式)
  • 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效(处理器通过MESI控制协议去维护内部缓存和其他处理器缓存的一致性,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的时效性,当发现自己缓存行对应的内存地址被修改,会将当前处理器的缓存行设置成无效状态,当需要对这个数据做修改时,会重新从内存加载)

Synchronized的实现原理与应用
Java中的每个对象都可以作为锁,具体表现为以下三种形式:

  • 对于普通同步方法,锁是当前实例对象
  • 对于静态同步方法,锁是当前类的Class对象
  • 对于同步方法块,锁是Synchronized括号里配置的对象

在JavaSE1.6中,锁一共有四种状态,级别从高到低分别为:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态
这几个状态会随着竞争情况逐渐升级,且锁可以升级但不能降级。
对象头
对象头组成:

  • Mark Word
  • 指向类的指针
  • 数组长度(只有数组对象才有)

运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。
在这里插入图片描述
原子操作的实现原理
原子操作是“不可被中断的一个或一系列操作”。
相关术语:
比较交换:CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有才交换成新值,否则不交换。
假共享:多个CPU同时修改一个缓存行的不同部分而引起其中一个CPU的操作无效。
处理器如何实现原子操作?

  • 总线锁定:此时其他处理器的请求将被阻塞,那么该处理器可以独占共享内存。
  • 缓存锁定:我们只需保证某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这会导致其他处理器不能操作其他内存地址的数据,开销比较大,目前处理器在某些场合用缓存锁定来代替总线锁定。“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作写内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性会组织同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。

Java中如何实现原子操作:
通过锁和循环cas实现,但是通过cas实现原子操作有三大问题:

  • ABA问题,解决办法为增加版本号。
  • 自旋cas如果长时间不成功,会给CPU带来非常大的执行开销。
  • 只能保证一个共享变量的原子操作,对多个共享变量操作时,循环cas无法保证原子性,这时候可以用锁。

使用锁实现原子操作:
锁机制保证了只有获得锁的线程才能操作锁定的内存区域,JVM内部实现了很多锁机制,有偏向锁、轻量级锁、互斥锁。除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程欲进入同步块的时候使用循环CAS的方式来获取锁,当他退出同步块的时候使用循环CAS来释放锁。
Java内存模型(JMM)的抽象结构

  • 所有实例域、静态域、数组元素都存储在堆中,堆内存在线程之间共享,以上三者统称为共享变量。局部变量、方法定义参数、异常处理器参数不会在线程之间共享,不会有内存可见性问题。
  • JMM决定了一个共享变量的写入何时对另一个线程可见。
  • 共享变量存储在主内存,每个线程也有一个私有的本地内存,存储了共享变量的副本。
  • 本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区(处理器使用它来临时保存向内存写入的数据)、寄存器等。
    在这里插入图片描述

从源代码到指令序列的重排序
重排序分为三种类型:编译器优化的重排序、指令级并行的重排序、内存系统的重排序。
从Java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

  • 源代码=>1.编译器优化重排序=>2.指令级并行重排序=>3.内存系统的重排序=>最终执行的指令序列
  • 其中1属于编译器重排序,2、3属于处理器重排序,这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序;对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,来禁用特定类型的处理器重排序。

并发编程模型的分类
每个处理器上的写缓冲区,仅仅对他所在的处理器可见。也就是说,处理器对内存的读写操作的执行顺序,不一定与内存实际发生的读写操作顺序一致!比如下面这个示例:
Processor A Processor B
a = 1; //A1
x = b; //A2
b = 2; //B1
y = a; //B2
初始状态:a = b = 0
处理器允许执行后得到结果:x = y = 0
在这里插入图片描述
这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果。此时A1和A2被重排序了。
由于现代处理器都会使用写缓冲区,所以现代的处理器都允许对写-读操作进行重排序。
在这里插入图片描述
为了保证内存的可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序:
在这里插入图片描述
StoreLoad Barriers是一个全能型屏障,它同时具有其他三个屏障的效果。
happens-before
在JMM中,如果一个操作执行的结果需要对另外一个操作可见,那么这两个操作之间必须要存在happens-before关系,这两个操作可以是在一个线程之内,也可以是在不同的线程之间。规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before于 B,B happens-before于 C,那么A happens-before于 C。
  • start()规则:如果线程A执行操作ThreadB.start()来启动线程B,那么线程A的ThreadB.start()操作happens-before于线程B中的任意操作。
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()成功返回。

一个happens-before规则对应于一个或多个编译器和处理器重排序规则。
重排序
一句话:重排序是编译器和处理器为了优化程序性能而对指令序列进行重新排序的手段。

  • 数据依赖性:如果两个操作访问同一个变量,且这两个操作中至少有一个是写操作,此时这两个操作就存在数据依赖性。
  • 数据依赖有三种类型:写后读(a=1;b=a),读后写(a=b;b=1),写后写(a=1;a=2)。

编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序,但这里所说的数据依赖性仅仅针对单个处理器中执行的指令序列和单个线程执行的操作,不同处理器和不同线程之间的数据依赖性不被编译器和处理器考虑。
as-if-serial语义
as-if-serial意思是不管怎么重排序,(单线程)程序的执行结果不能改变。为了遵守此语义,编译器和处理器不会对存在数据关系的操作做重排序,因为这种重排序会改变执行结果。如果操作间不存在数据依赖,这些操作就可能被编译器和处理器重排序。这就给程序员创建了一个幻觉:单线程程序是顺序执行的。
程序顺序规则
double pi=3.14 //A
double r=1.0 //B
double area=pi x r x r //C
这里A happens-before C,B happens-before C,A happens-before C。
1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前(这只是Java内存模型向程序员做出的保证)。
2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
happens-before关系本质上和as-if-serial语义是一回事。
重排序对多线程的影响

public ReorderExample{
	int a = 0;
	boolean flag =false;
	public void writer(){
		a = 1;//1
		flag=true;//2
	}
	public void reader(){
		if(flag) { //3
			int i = a * a;//4
		}
	}
}

1和2,3和4都有重排序的可能,在单线程程序中,对存在控制依赖操作的重排序,不会改变执行结果,这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因;但在多线程程序中,对存在控制依赖的操作重排序,可能会改变执行结果。
顺序一致性
顺序一致性内存模型是一个理论(理想化)参考模型。他有两大特性:

  • 一个线程中的所有操作必须按照程序的顺序来执行
  • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序,每个操作都必须原子执行且立即对所有线程可见。

但是,JMM中没有相关的保障。未同步的程序不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。
顺序一致性内存模型和JMM关系:

  • 对于正确同步的程序,保证结果正确,且JMM尽可能为编译器和处理器的优化打开方便之门。
  • 对于未同步的程序,一,顺序一致性模型保证单线程内的操作会按程序的顺序进行,而JMM不保证;二,顺序一致性模型保证所有线程只能看到一致的操作顺序,而JMM不保证;三,JMM不保证对64位的long和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存的读写都具有原子性。从JSR-133内存模型开始,即从JDK5开始,仅仅只允许把一个64位的long或double变量的写操作拆分成两个32位的写操作来执行,任意的读操作都必须具有原子性。

volatile的内存语义
理解volatile的一个好方法就是把对volatile变量的单个读、写,看成是使用同一个锁对这些单个读写操作做了同步。

    volatile long v1 = 1L;
    public void set(long v1) {
        v1 = 1;
    }
    public void getAndIncrement() {
        v1++;
    }
    public long get() {
        return v1;
    }

等价于

    long v1 = 1L;
    public synchronized void set(long v1) {
        v1 = 1;
    }
    public void getAndIncrement() {
        long temp = get();
        temp += 1L;
        set(temp);
    }
    public synchronized long get() {
        return v1;
    }

简而言之,有以下特性:

  • 可见性:当写一个volatile变量时,JMM会把线程对应的本地内存中的共享变量刷新到主内存。对一个volatile变量的读,JMM会把线程对应的本地内存置为无效,接下来将从主内存中读取共享变量,所以总是能看到任意线程对这个volatile变量最后的写入(volatile的内存语义就是这句话)。
  • 原子性:对任意单个的volatile变量的读写具有原子性(包括long型变量),但volatile++这种复合操作不具备原子性。

volatile的实现原理
重排序分为编译器重排序和处理器重排序,为了实现volatile内存语义,JMM会限制这两种类型的重排序类型。JMM针对编译器规定的volatile重排序规则表:
在这里插入图片描述
由上图可知:
1.当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个操作确保volatile写之前的操作不会被编译器重排序到volatile写之后。
2。当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读后面的操作不会被编译器重排序到volatile读之前。
3.当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的重排序。对于编译器,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略:
1.在每个volatile写前插入一个storestore内存屏障
2.在每个volatile写后插入一个storeload内存屏障
3.在每个volatile读后插入一个loadload内存屏障
4.在每个volatile读后插入一个loadstore内存屏障

storestore屏障可以保证在volatile写之前,其前面所有的普通写操作已经对任何处理器可见,因为它将保障将上面所有普通写在volatile写之前刷新到内存。
锁的内存语义
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。当线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取变量。
所以,锁释放与volatile写有相同的内存语义,锁获取与volatile读有相同的内存语义。
锁内存语义的实现p50
锁释放-获取的内存语义的实现至少有以下两种方式:
1.利用volatile变量的写-读所具有的内存语义
2.利用cas所附带的volatile读和volatile写的内存语义
current包的实现
由于Java的cas同时具有volatile读和volatile写的内存语义,因此Java线程之间的通信,有下面四种:
1.A线程写volatile变量,随后B线程读这个volatile变量
2.A线程写volatile变量,随后B线程用cas更新这个volatile变量
3.A线程用cas更新一个volatile变量,随后B线程用cas更新这个volatile变量
4.A线程用cas更新一个volatile变量,随后B线程读这个volatile变量
仔细分析current源码包就会发现,他们有一个通用的实现模式:
1.首先,声明共享变量为volatile
2.然后,使用cas的原子条件更新来实现线程之间的同步
3.同时,配合以volatile的读/写和cas所具有的volatile读和写的内存语义来实现线程之间的通信
final域的内存语义
对于final域,编译器和处理器要遵守两个重排序规则:
1.在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作不能重排序。
2.初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作不能重排序。
写final域的重排序规则
写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包括以下两个方面
1.JMM禁止编译器把final域的写重排序到构造函数之外。
2.编译器会在final域的写之后,构造函数return之前,插入一个storestore屏障,这个屏障禁止处理器把final域的写重排序到构造函数之外。
以上规则可以保障,在对象引用被任意线程可见之前,对象的final域已经被正确初始化过了。
读final域的重排序规则
在一个线程中,初次读对象引用与初次读该对象的final域,JMM禁止处理器重排序这两个操作(这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个loadload屏障。
以上规则确保,在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。
final域为引用类型
对于引用类型,写final域的重排序规则对编译器和处理器做了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给另外一个引用变量,这两个操作之间不能重排序。
happens-before相关
JSR-133使用happens-before来指定两个操作之间的执行顺序。由于两个线程可以在一个线程之内,也可以在不同线程之间。因此,JMM通过happens-before关系向程序员提供跨线程的内存可见性保证。
定义:
1.如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2.两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before执行的结果一致,那么这种重排序不非法。
1只是JMM对程序员的承诺,2是JMM对编译器和处理器重排序的约束原则。JMM其实是遵循一个基本原则:
只要不改变程序结果(指的是单线程和正确同步的多线程程序),编译器和处理器怎么优化都行。
因此,happens-before和as-if-serial是一回事,as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before保证正确同步的多线程程序的执行结果不被改变。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值