Java并发:多线程安全总结

1.并发基础

定义:一个cpu“同时”处理多个任务,而多个线程都在争取这个cpu资源

1.1 优点

  • 充分发挥多核CPU的计算能力
  • 方便进行业务拆分,提升应用性能

1.2 缺点

  • 频繁切换上下文耗时
  • 线程安全问题:原子性、有序性、重排序

1.3 相关概念

  • 同步、异步:分别在于是否被调用的方法结束后,调用者后面的代码才能执行
  • 并发、并行:前者指一个cpu通过切换时间片“同时”处理多个任务;后者指真正意义上的同时进行,需要多个CPU
  • 阻塞、非阻塞:如果一个线程占用了临界资源,那么其他线程只有等待该资源释放方可继续执行,此时等待的线程被挂起,即阻塞;非阻塞相反,强调没有一个线程可以阻塞其他线程,所有的线程都会尝试地往前运行
  • 临界区:表示一种可以被多个线程使用公共资源或者共享数据,当一个线程占用时其他线程必须等待

2.线程基础

2.1 创建线程的方式

  • 继承Thread,重新run方法(局限:只能单继承)
  • 实现Runable,传给Thread
  • 实现callable,submit到线程池

2.2 线程的状态

2983970-f7165db617d2da0e.png
java并发_线程的状态.png
  • NEW 初识状态,被创建但未start()
  • RUNNABLE 运行状态(实际上在系统调度情况下可以分为RUNNING和READY状态)
  • BLOCKED 阻塞状态,线程阻塞于锁
  • WAITING 等待转态,等待其他线程通知或中断
  • TIMED_WAITING 超时等待,当超时等待时间到达后,线程会切换到Runable的状态
  • TERMINATED 终止状态,线程执行完毕

2.3 线程状态基本操作

2.3.1 stop

  • 暴力停止,已被废弃

2.3.2 interrupt / isInterrupted / interrupted

  • interrupt:中断该线程对象,如果该线程调用了wait()/wait(long)/sleep()/sleep(long)/join()/join(long)方法时会抛出InterruptedException并清除标志位(线程的中断可以理解为一个标志位,表示一个运行中的线程是否被其他线程中断)
  • isInterrupted:判断该线程是否被中断,不会清除标志位
  • interrupted:判断该线程是否被中断,会清除标志位

2.3.3 join

  • 当前线程只有等加入的进程执行完之后才能继续执行,否则一直阻塞

2.3.4 sleep

  • 休眠,Thread的静态方法,让出CPU时间片,不会释放锁,任意地方可以调用,休眠时间结束则进入线程池等待下一次获取资源

2.3.5 wait

  • 等待,Object实例方法,让出CPU时间片,同时释放锁,只能在同步代码中调用,等待其他线程notify()/notifyAll()后离开等待池,再次获取CPU后继续执行

2.3.6 yield

  • 静态方法,暂时让出CPU,让出的时间片只会配给线程优先级(priority)相同的线程竞争

2.4 Daemon守护线程

  • 通过setDaemon(true)将该线程设置为守护线程,需要在其start()方法调用之前设置
  • 守护线程会在被守护线程结束后自动结束,但是不会执行finally代码块

3.Java内存模型(JMM)

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

多线程条件下,会涉及多个线程间相互通信;为了性能优化,还会涉及编译器指令重排序和处理器指令重排序等问题。

而出现线程安全的主要原因就是主内存和工作区内存数据不一致,理解这些问题的核心在于JMM

3.1 内存模型抽象结构

并发编程中主要需要解决两大问题,即线程之间的通信和同步问题。通信主要有两种机制:共享内存和消息传递。Java选择的是共享内存的方式。

3.1.1共享变量类型

在java程序中所有实例域,静态域和数组元素都是放在堆内存中(所有线程均可访问到,是可以共享的),而局部变量,方法定义参数和异常处理器参数不会在线程间共享。共享数据会出现线程安全的问题,而非共享数据不会出现线程安全的问题。

3.1.2抽象结构模型

  • CPU的处理速度和主存的读写速度不是一个量级的,为了平衡这种巨大的差距,每个CPU都会有缓存。
  • 共享变量会先放在主存中,每个线程都有属于自己的工作内存,并且会把位于主存中的共享变量拷贝到自己的工作内存,之后的读写操作均使用位于工作内存的变量副本,并在某个时刻将工作内存的变量副本写回到主存中去。
  • 若线程A更新后数据并没有及时写回到主存,而此时线程B读到的是过期的数据,这就出现了“脏读”现象。

3.2 重排序

为了提高性能,编译器和处理器常常会对指令进行重排序;编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序

3.3 happens-before

JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证:如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见

基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。主要规则如下:

  • 1 程序顺序规则:一个线程中的每个操作happens-before于该线程中的任意后续操作
  • 2 监视器锁(同步)规则:对于一个监视器的解锁,happens-before于随后对这个监视器的加锁;线程A释放锁happens-before线程B加锁,代表线程A对数据的更改对线程B可见

3.4 并发数据安全

从JMM内存抽象结构hehappens-before规则看来,单线程情况下,不会出现数据安全问题,但多线程情况下,可能出现数据脏读问题,多线程开发时需要从原子性,有序性,可见性三个方面进行考虑

4Synchronized与对象锁机制

4.1 Synchronized

  • 同步方法
//实例方法,锁住的是该类的实例对象
public synchronized void method(){}
//静态方法,锁住的是该类的类对象
public static synchronized void method(){}
  • 同步代码块
//锁住的是该类的实例对象
synchronized(this){}
//锁住的是该类的类对象
synchronized(Object.class){}
//锁住的是配置的实例对象
synchronized(new Obeject()){}

注:如果锁的是类对象的话,尽管new多个实例对象,但他们仍然是属于同一个类依然会被锁住,即线程之间保证同步关系。

4.2 对象锁机制

4.2.1 Java对象头

  • 在同步的时候取到对象的锁其实是获取对象的monitor。monitor类似对对象的一个标志,这个标志存放在Java对象的对象头。Java对象头里的Mark Word里默认的存放的对象的Hashcode,分代年龄和锁标记位。
  • Monitor是线程私有的数据结构。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

4.2.2 synchronized实现

  • synchronized修饰的方法的字节码文件中,该方法被monitorenter和monitorexit包裹:执行同步代码块后首先要先执行monitorenter指令,退出的时候monitorexit指令,使用Synchronized进行同步,其关键就是必须要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待,阻塞在同步代码块或同步方法入口,进入BLOCKED状态。
  • 释放锁的时候会将线程操作的数据刷新到主内存中,其他线程获取锁时会强制从主内存中获取最新的值

5锁

锁按照不同的方式可以分为多种类型,以下总结了常见的分类方式:

5.1 独享锁 VS 共享锁

  • 独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁,即在同一时刻只有一个线程能够获得对象的监视器(monitor),同时会阻塞其他所有线程获取该锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。
  • 共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。如ReentrantReadWriteLock,读锁是共享锁,写锁是独享锁,其并发性相比一般的互斥锁有了很大提升。

5.2 悲观锁 VS 乐观锁

  • 对于同一数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。如JDK中的synchronized和JUC中Lock的实现类。
  • 乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。主要通过无锁编程,实现方式是通过CAS算法,Java原子类中的递增操作就通过CAS自旋实现的,在JUC包中利用CAS实现类也有很多。

5.3 自旋锁 VS 适应性自旋锁

  • 阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
  • 自旋锁则使请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。且可通过修改PreBlockSpin来更改自旋次数最大值,防止锁被占用的时间很长时自旋的线程浪费处理器资源
  • 适应性自旋锁在JDK1.6引进,自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

5.4 CAS

5.4.1 原理

  • CAS(compare and swap)通过比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。可以通俗的理解为CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值。
  • 当且仅当 V 的值等于 O 时,CAS通过原子方式用新值N来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。
  • 一般情况下,“更新”是一个不断重试的操作。

5.4.2 存在问题及解决方案

  • ABA:如果在CAS过程中,一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。
  • 循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
  • 只能保证一个共享变量的原子操作:一个解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。然后将这个对象做CAS操作就可以保证其原子性。atomic中提供了AtomicReference来保证引用对象之间的原子性。

5.5 无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁

如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,也JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
Java对象头里的Mark Word记录了锁的4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。

  • 无锁:无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能非常高。

  • 偏向锁:一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价,在Mark Word里存储锁偏向的线程ID。只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。

  • 轻量级锁:当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

  • 重量级锁:当锁是偏向锁的时候,若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。将除了拥有锁的线程以外的线程都阻塞。

6volatile关键字

6.1 简介

  • 被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。相对于synchronized的阻塞式同步(在线程竞争激烈的情况下会升级为重量级锁),volatile可以说是java虚拟机提供的最轻量级的同步机制。

6.2 实现原理(缓存一致性)

  • 在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令;
  • Lock前缀的指令会引起处理器缓存写回内存;
  • 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;
  • 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

6.3 与happens-before关系

  • 对一个volatile域的写,happens-before于任意后续对这个volatile域的读;保证每个线程都能获得最新值,即满足数据的“可见性”。

  • 为了实现volatile内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对volatile域操作前的指令不能重排序到对volatile域操作后。

7final关键字

final可以修饰变量,方法和类,用于表示所修饰的内容一旦赋值之后就不会再被改变,而这个值得属性可以改变

7.1 final变量

当final变量未初始化时系统不会进行隐式初始化

7.1.1 final成员变量:

  • 修饰类变量(静态变量):必须要在静态初始化块中指定初始值或者声明该类变量时指定初始值,而且只能在这两个地方之一进行指定;
  • 修饰实例变量(非静态变量):必要要在非静态初始化块,声明该实例变量或者在构造器中指定初始值,而且只能在这三个地方进行指定。

7.1.1 final局部变量:

  • 使用之前必须进行显式初始化,且已经进行了初始化则后面就不能再次进行更改

7.2 final方法

  • 修饰方法,方法不能被重写,可以重载

7.3 final类

  • 修饰类,类不能被继承

7.4 final与重排序

7.4.1 final基本数据类型

  • 写final域重排序规则:JMM禁止编译器把final域的写重排序到构造函数之外(编译器会在final域写之后,构造函数return之前,插入一个storestore屏障)
  • 读final域重排序规则:JMM会禁止初次读对象引用和初次读该对象包含的final域两个操作的重排序,即在读一个对象的final域之前,一定会先读这个包含这个final域的对象的引用。实际上读对象的引用和读该对象的final域存在间接依赖性,一般处理器不会重排序这两个操作。

7.4.2 final引用数据类型

  • 在构造函数内对一个final修饰的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋给一个引用变量,这两个操作是不能被重排序的。

注:对final域写重排序规则可以确保我们在使用一个对象引用的时候该对象的final域已经在构造函数被初始化过了,这一点还有一个前提是:在构造函数中,不能让这个被构造的对象被其他线程可见(虽然一个对象的final域构造函数return前被初始化,但是如果该对象的引用被其他线程使用,会导致在构造函数执行的过程中其他线程通过该对象获取到还未初始化的final域)

8多线程安全性总结

处理多线程数据安全,指的就是处理多线程情况下数据的原子性、有序性和可见性问题

8.1 原子性

指一个操作是不可中断的,要么全部执行成功要么全部执行失败

  • java内存模型中定义了8种原子操作:lock(锁定)、unlock(解锁)、read(读取)、load(载入)、use(使用)、assign(赋值)、store(存储)、write(操作)
  • 大致可以认为基本数据类型的访问读写具备原子性
  • 如果我们需要更大范围的原子性操作就可以使用lock和unlock原子操作,其主要实现为synchronized
  • 让volatile保证原子性,必须符合以下两条规则:运算结果并不依赖于变量的当前值,或者能够确保只有一个线程修改变量的值;变量不需要与其他的状态变量共同参与不变约束

8.2 有序性

  • synchronized语义要求线程在访问读写共享变量时只能“串行”执行,因此synchronized具有有序性。
  • volatile包含禁止指令重排序的语义,其具有有序性。有volatile修饰的变量,赋值前后添加了内存屏障,指令重排序时不能把后面的指令重排序到内存屏障之前的位置

8.3 可见性

  • 当线程获取锁时会从主内存中获取共享变量的最新值,释放锁的时候会将共享变量同步到主内存中。因此,synchronized具有可见性。
  • 在volatile中,会通过在指令中添加lock指令,以实现内存可见性

8.4 总结

  • synchronized: 具有原子性,有序性和可见性;
  • volatile:具有一定有序性和可见性

参考:
你听___的博客
不可不说的Java“锁”事

猜你喜欢

转载自blog.csdn.net/weixin_34288121/article/details/90854328