当前位置:   article > 正文

java多线程并发

java多线程并发

一、原子性+有序性+可见性

要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

1、原子性

为了直观地了解什么是原子性,我们看下下面哪些操作是原子性操作

int count = 0; // 1
count++; // 2
int a = count; // 3
  • 1
  • 2
  • 3

除了语句1是原子操作,其它两个语句都不是原子性操作,下面我们来分析一下语句2

其实语句2在执行的时候,包含三个指令操作:

  • 指令 1:首先,需要把变量 count 从内存加载到 CPU 的高速寄存器
  • 指令 2:之后,在寄存器中执行 +1 操作
  • 指令 3:最后,将结果写入内存

对于上面的三条指令来说,如果线程 A 在指令 1 执行完后做线程切换,线程 B 执行完3个指令,又切换回线程 A ,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。这也是经典的缓存一致性问题。这个能被多个线程访问的变量count称为共享变量
在这里插入图片描述

注意:操作系统做任务切换,可以发生在任何一条CPU 指令执行完

2、有序性

为了性能优化,编译器和处理器会进行指令重排序,有时候会改变程序中语句的先后顺序,在多线程并发运行的程序中,这种优化可能会导致执行结果的变化。

例如,在单例模式中,如下:

public class Singleton {
   
	static Singleton instance;
	static Singleton getInstance() {
    // 返回实例
		if (instance == null) {
   
			synchronized(Singleton.class) {
    // 同一时间只能有一个线程持有类Singleton的class对象
				if (instance == null)
					instance = new Singleton(); // 创建实例
				}
			}
		return instance;
		}
	}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

创建实例的语句 instance = new Singleton() 未被编译器优化的指令执行顺序:

  • 指令 1:分配一块内存 M
  • 指令 2:在内存 M 上初始化 Singleton 对象
  • 指令 3:将 M 的地址赋值给 instance 变量

编译器优化后:

  • 指令 1:分配一块内存 M
  • 指令 2:将 M 的地址赋值给 instance 变量
  • 指令 3:然后在内存 M 上初始化 Singleton 对象。

假如现在有A、B两个线程,线程 A 先执行 getInstance() 方法,执行完优化后的指令2时,发生了线程切换,线程B开始执行该方法,执行到第一次判断 instance==null 会发现 instance 不等于 null 了,所以直接返回 instance ,而此时的 instance 是没有初始化过的,与预期不符。现行的比较通用的做法就是采用静态内部类的方式来实现单例模式:

public class SingletonDemo {
   
	private SingletonDemo() {
   
    }
    private static class SingletonDemoHandler {
    // 静态内部类
		private static SingletonDemo instance = new SingletonDemo(); // 静态成员变量,在这个内部类加载的时候,就被加入到<clinit>()方法中运行
	}
	public static SingletonDemo getInstance() {
   
		return SingletonDemoHandler.instance; // 创建内部类实例,此时加载内部类到 JVM
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

3、可见性

指的是当一个线程修改了共享变量后,其他线程能够立即得知这个修改。

首先看一下 java 内存的结构:
在这里插入图片描述

  • 我们定义的所有变量都储存在主内存中
  • 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)
  • 线程对共享变量所有的操作都必须在自己的工作内存中进行,不能直接从主内存中读写
  • 不同线程之间也无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来进行

线程1对共享变量的修改要被线程2及时看到的话,要经过如下步骤:

  • 把工作内存1中更新的变量值刷新到主内存
  • 把主内存中变量的值更新到工作内存2

二、如何保证以上三性

1、硬件层面解决

回到缓存不一致问题,也就是破坏了java语句原子性的问题。如果一个变量在多个CPU(或一个CPU的不同时间段)中都存在缓存,就可能存在缓存不一致的问题。为了解决缓存不一致性问题,通常来说在硬件层面有以下 2 种解决方法:

1)在总线加 LOCK# 锁

在早期的 CPU 中,通过在总线上加 LOCK# 锁可以解决缓存不一致问题。因为 CPU 和其他部件进行通信都是通过总线来进行的,如果对总线加 LOCK# 锁的话,也就能够阻塞其他 CPU 对其他计算机部件(如内存)的访问,从而使得同一时刻只能有一个 CPU 使用这个变量的内存数据。比如上面一节的例子中,如果一个线程在执行 count++,在执行这段代码的过程中,总线上发出了 LOCK# 锁的信号,那么只有等待这段代码完全执行完毕之后,其他 CPU 才能再从内存读取变量count的值,然后进行操作。这样就解决了缓存不一致的问题。

但是上面的方式导致用 LOCK# 锁住总线的期间,其他 CPU 无法访问内存,导致线程并发效率低下。

2)使用缓存一致性协议

为了保证一定的并发效率,不使用总线加锁的方式,缓存一致性协议出现了。最出名的就是英特尔的 MESI 协议,MESI协议保证了每个缓存中使用的共享变量副本是一致的。它核心的思想是:当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他 CPU 中也可能存在该变量的副本,就会发出信号通知其他 CPU 将该变量的缓存置为无效状态,因此当其他 CPU 需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取

此种方法比锁总线的方法效率更高,当锁总线的方法更加通用。缓存一致性协议也是volatile的一部分底层实现保证。

2、软件层面解决

1)背景:JVM内存模型

即前一节可见性中介绍的内存模型,它是在软件侧面解决 java 并发问题的基础,它能够屏蔽各个硬件平台和操作系统的内存访问差异,实现让 java 程序在各种平台下都能达到一致的内存访问效果。

java 内存模型定义了程序中变量的访问规则,往大一点说,定义了程序执行的次序。注意,为了获得较好的执行性能, Java 内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在 java 内存模型中,也会存在缓存一致性问题和指令重排序的问题。所以,Java内存模型的设计并没有直接解决三性问题,而是提供了解决的基础。

2)原子性

在java中,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

注意:在 32 位平台下,对 64 位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的 JDK 中,JVM 已经保证对 64 位数据的读取和赋值也是原子性操作了。

java 内存模型只保证了基本读取和常量赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronized、Lock或CAS机制来实现。

3)可见性

对于可见性,Java提供了 volatile 关键字来保证。

当一个共享变量被 volatile 修饰时,它的值一旦被修改,就会被立即更新到主存,当其他线程要读取该值时,会直接去内存中读取新值,而不是在自己的私有工作空间读取旧值。未被volatile修饰的共享变量不能保证可见性,因为被修改之后,最新的值什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值。

另外,synchronized 和 Lock 也能够保证可见性,因为synchronized 和 Lock 能保证同一时刻只有一个线程获取锁,然后执行同步代码,所以不存在共享变量,也就不会有别的线程来修改已经被 synchronized 和 Lock 锁定的变量。在释放锁之前会将对变量的修改刷新到主存当中。

4)有序性

Volatile能够禁止指令重排序,但synchronized 和 Lock不会禁止指令的重排序,因为它们将线程编程了串行执行的,即时重排序也不会影响串行执行线程的执行结果,所以也就不禁止了。

另外,Java 内存模型具备一些先天的有序性,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从 happens-before 原则推导出来,那么就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

happens-before原则(先行发生原则):

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作
  • volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 传递规则:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作C
  • 线程启动规则:Thread 对象的 start() 方法先行发生于此线程内的每个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

前 4 条规则是比较重要的,后 4 条规则都是显而易见的,总结下来就是:

  1. 没有依赖关系的指令的顺序可以随意调整
  2. 同步区的锁必须被释放后,下一个线程才能获取这把锁
  3. 写先于读
  4. 传递性,A先于B没问题,B先于C也没问题,那么A先于C也没问题

三、java对象的内存布局

此部分内容来自马士兵教育在 b 站的 jvm 公开课,此部分和锁的实现较为相关

java对象的内存布局,即java对象在堆中存储时的内存布局。在maven中引入工具JOL(java object layout)后,可以使用System.out.println(ClassLayout.parseInstance(new Object()).toPrintable();输出java对象的内存布局:
在这里插入图片描述
new一个对象,此对象的内存布局如下:
在这里插入图片描述
每个对象都拥有锁,都可以被独占,对象头中的 markword 是用来保存锁状态的,非常重要,会在锁升级部分中讲解markword具体保存什么数据

1、markword:8字节,记录锁这个对象的信息

2、class pointer:指向该对象的Class对象,markword和class pointer合起来就是对象头。默认情况下,JVM开启了指针压缩,此部分占4字节,若不开启指针压缩,此部分在64位机器上就是8字节。通过jvm参数-XX:+UseCompressedClassPointers可以设置。另一个参数叫做-XX:+UseCompressedOops意为普通对象指针压缩,如指向String常量的String类型引用变量就是这种指针,被压缩为4字节

3、instance data:成员变量的引用或基础变量直接存储

4、padding:使得整个对象占有的内存达到8字节的倍数,因为总线读内存的时候,按照8字节的倍数读,如果分开存储就会变慢,所以padding是用来提高效率用的

数组的内存布局:
在这里插入图片描述

四、synchronized

1、介绍

此关键字用来锁定一个对象。纠正一个错误:代码是不会被锁定的,只能锁定对象

Object o = new Object();
synchronized(o){
   
	// 执行这段代码的时候,对象o被此线程独占
}

synchronized void methodName() {
   
	// 此方法执行过程中,this对象被锁定
}

static synchronized void methodName() {
   
	// 此方法执行过程中,this对象的class对象被锁定
	// 相当于写成:
	// synchronized(Main.class)
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

具体实现过程:
1、代码层面:synchronized
2、字节码层面:在互斥代码区前面加 monitor enter ,在互斥代码区最后加 monitor exit
3、JVM层面:进行锁升级
4、汇编指令:lock cmpxchg,利用计算机系统的mutex Lock实现。每一个可重入锁都会关联一个线程ID和一个锁状态status,所以很好做重入操作的判断。

2、锁升级(无锁、偏向锁、轻量级锁、重量级锁)

JDK1.6 为了提升性能,减少获得锁和释放锁所带来的消耗,引入了4种锁的状态:无锁(new)、偏向锁、轻量级锁(自旋锁、无锁CAS)和重量级锁,它会随着多线程竞争变得激励而逐渐升级。锁降级是gc中的一个过程,就是没有任何线程访问这个对象,只有gc线程访问它,所以说降级没有任何意义,所以可以认为锁降级不存在。

  1. 无锁
    无锁状态其实就是乐观锁
  2. 偏向锁
    Java偏向锁(Biased Locking)是指它会偏向于第一个访问这个锁的线程。如果在运行过程中,只有一个线程访问某对象,不存在多线程竞争的情况,那么线程是不需要重复获取这个锁的,这种情况下,就会给这个对象加一个偏向锁,当下次这个线程又需要锁定此对象时,直接使用这个临界资源即可,无需重新上锁。某对象为偏向锁状态时,它的Mark Word包含了指向这个线程的指针。
  3. 轻量级锁
    一旦有别的线程来竞争锁了,就立刻升级为轻量级锁。具体升级过程如下:首先撤掉 markword 里面指向第一个线程的那个指针,然后正在竞争的几个线程都在自己的线程栈内创建一个 lock record 对象,一旦某个线程抢到了这个锁,就在markword里存:指向抢到锁的线程对应lock record对象的指针。抢的过程是自旋的过程,即CAS。
  4. 重量级锁
    如果线程并发进一步加剧,线程的自旋超过了一定次数,或者CPU有一半的核都在执行自旋,轻量级锁就会升级为重量级锁,重量级锁会使除了此时拥有锁的线程以外的线程都阻塞(不自旋,也就不消耗CPU资源)。升级到重量级锁其实就是互斥锁了,一个线程拿到锁,其余线程都会处于阻塞等待状态。实际上重量级锁是OS内核分配的,内核中有一定数量mutex(互斥锁),当要升级为重量锁时,内核会为某对象分配一个mutex,markword内部就会存有指向这个mutex的指针。

在 Java 中,synchronized 关键字内部实现原理就是锁升级的过程:无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁。synchronized默认是一个非公平锁,所有挂起的线程会被放在等待队列中,这个队列默认是无序的。在hotspot中,不同的锁状态对应markword为:
在这里插入图片描述
图源:马士兵教育在b站的jvm公开课视频

在实际运行中,要获得锁状态,会首先检查最右两位“锁标志位”,然后检查偏向锁位,即可分出5种状态来。

上图中的分代年龄即gc回收需要使用的对象年龄记录。ps/po中的默认值为15,CMS中的默认值为6,分代年龄用4位表示,最大就是15,不能再更大了。

五、volatile

1、Volatile可以保证单个共享变量可见性

一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义:

  • 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程(其他cpu)来说是立即可见的
  • 禁止进行指令重排序(后面再说)

先看一段代码,假如线程 1 先执行,线程 2 后执行:

//线程1
boolean stop = false;
while(!stop){
   
    doSomething();
}
 
//线程2
stop = true;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

这是一段很典型的代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,线程1不一定会被成功中断。在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。

因为线程1和2在运行的时候,会将 stop 变量的值拷贝一份放在自己的工作内存当中。当线程 2 更改了 stop 变量的值之后,有可能在还没来得及写入主存中的时候,就发生了线程切换,线程 2 被迫阻塞,而线程1不知道线程 2 对 stop 变量的更改,会一直循环下去(假设是多核CPU)。

但是用 volatile 修饰之后,在工作内存中被修改的值会立即写入主存,且当线程 2 修改stop时,线程 1 的工作内存中缓存变量 stop 的缓存行被置为无效状态,线程 1 只能再次从内存读取变量 stop 的值,才能接着使用。

2、Volatile不保证原子性

看例子:

public class Main {
   
    volatile int inc = 0;

    void increase() {
   
        inc+&#
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/寸_铁/article/detail/793862
推荐阅读
相关标签
  

闽ICP备14008679号