Java多线程 | 02 | 线程同步机制

同步机制简介

​ 线程同步机制是一套用于协调线程之间的数据访问的机制。该机制可以保障线程安全。Java平台提供的线程同步机制包括: 锁,volatile关键字,final关键字,static关键字,以及相关的API,如Object.wait()/Object.notify()等

​ 线程安全问题的产生前提是多个线程并发访问共享数据。

​ 将多个线程对共享数据的并发访问转换为串行访问,即一个共享数据一次只能被一个线程访问。锁就是用这种思路来保障线程安全的。锁(Lock)可以理解为对共享数据进行保护的一个许可证,对于同一个许可证保护的共享数据来说,任何线程想要访问这些共享数据必须先持有该许可证。一个线程只有在持有许可证的情况下才能对这些共享数据进行访问,并且一个许可证一次只能被一个线程持有。

​ 线程在结束对共享数据的访问后必须释放其持有的许可证。

​ 一线程在访问共享数据前必须先获得锁,获得锁的线程称为锁的持有线程。一个锁一次只能被一个线程持有,锁的持有线程在获得锁之后和释放锁之前这段时间所执行的代码称为临界区(CriticalSection)。锁具有排他性(Exclusive),即一个锁一次只能被一个线程持有,这种锁称为排它锁或互斥锁(Mutex)

​ JVM把锁分为内部锁和显示锁两种,内部锁通过synchronized关键字实现;显示锁通过java.concurrent.locks.Lock接口的实现类实现

锁的作用

​ 锁可以实现对共享数据的安全访问。保障线程的原子性,可见性与有序性。

  • 锁是通过互斥保障原子性。一个锁只能被一个线程持有,这就保证临界区的代码一次只能被一个线程执行。使得临界区代码所执行的操作自然而然的具有不可分割的特性,即具备了原子性。

  • 可见性的保障是通过写线程冲刷处理器缓存和读线程刷新处理器缓存这两个动作实现的。在java平台中,锁的获得隐含着刷新处理器缓存的动作,锁的释放隐含着冲刷处理器缓存的动作。

  • 锁能够保障有序性,写线程在临界区所执行的代码在读线程所执行的临界区看来像是完全按照源码顺序执行的。

使用锁保障线程的安全性,必须满足以下条件:这些线程在访问共享数据时必须使用同一个锁即使是读取共享数据的线程也需要使用同步锁

锁相关的概念

  • 可重入性:可重入性(Reentrancy)描述这样一个问题,一个线程持有该锁的时候能再次(多次)申请该锁。如果一个线程持有一个锁的时候还能够继续成功申请该锁,称该锁是可重入的,否则就称该锁为不可重入的
  • 锁的征用与调度:Java平台中内部锁属于非公平锁,显示Lock锁既支持公平锁又支持非公平锁,后续展开讲
  • 锁的粒度:一个锁可以保护的共享数据的数量大小称为锁的粒度,锁保护共享数据量大,称该锁的粒度粗,否则就称该锁的粒度细。锁的粒度过粗会导致线程在申请锁时会进行不必要的等待,锁的粒度过细会导致频繁调用锁,增加锁调度的开销。

内部锁 synchronized

任意对象都可以作为同步锁。所有对象都自动含有单一的锁(监视器)。

同步方法的锁:

  • 静态方法(类名.class
  • 非静态方法(this
  • 同步代码块:自己指定,很多时候也是指定为this类名.class或者用常量
  • 必须确保使用同一个资源的多个线程共用一把锁,这个非常重要,否则就无法保证共享资源的安全
  • 一个线程类中的所有静态方法共用同一把锁(类名.class),所有非静态方法共用同一把锁(this),同步代码块指定需谨慎

同步的范围

1、如何找问题,即代码是否存在线程安全问题?(非常重要

(1)明确哪些代码是多线程运行的代码

(2)明确多个线程是否有共享数据

(3)明确多线程运行代码中是否有多条语句操作共享数据

2、如何解决呢?(非常重要)

对多条操作共享数据的语句,只能让一个线程都执行完,在执行过程中,其他线程不可以参与执行。即所有操作共享数据的这些语句都要放在同步范围中

3、切记

范围太小:没锁住所有有安全问题的代码

范围太大:没发挥多线程的功能。

锁的释放

释放锁的操作

  • 当前线程的同步方法、同步代码块执行结束。
  • 当前线程在同步代码块、同步方法中遇到break、return终止了该代码块、该方法的继续执行。
  • 当前线程在同步代码块、同步方法中出现了未处理的Error或Exception,导致异常结束。
  • 当前线程在同步代码块、同步方法中执行了线程对象的wait()方法,当前线程暂停,并释放锁。

不会释放锁的操作

  • 线程执行同步代码块或同步方法时,程序调用Thread.sleep()、Thread.yield()方法暂停当前线程的执行
  • 线程执行同步代码块时,其他线程调用了该线程的suspend()方法将该线程挂起,该线程不会释放锁(同步监视器)。
  • 应尽量避免使用suspend()和resume()来控制线程
  • 同步过程中线程出现异常,会自动释放锁对象

示例

public class SynchonizedTest {
public static void main(String[] args) {
SynchonizedTest test = new SynchonizedTest();
SynchonizedTest test2 = new SynchonizedTest();
new Thread(new Runnable() {
@Override
public void run() {
test.add();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
// test.add(); //同一把锁,线程同步
test2.add(); //不是同一把锁,无法同步
}
}).start();
} public void add(){
synchronized (this) { //锁为this对象
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + "-->" + i);
}
}
}
}

死锁

什么是死锁

​ 不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的死锁

​ 出现死锁后,不会出现异常,不会出现提示,只是所有的线程都处于阻塞状态,无法继续

出现死锁的原因

​ 在多线程程序中,同步时可能需要使用多个锁,如果获得锁的顺序不一致,可能会导致死锁

如何避免

当需要获得多个锁时,所有线程获得锁的顺序保持一致即可

示例

public class DeadLock {
public static void main(String[] args) {
SubThread thread1 = new SubThread();
thread1.setName("a");
thread1.start(); SubThread thread2 = new SubThread();
thread2.setName("b");
thread2.start();
}
static class SubThread extends Thread{
public static final Object lock1 = new Object();
public static final Object lock2 = new Object(); @Override
public void run() {
if ("a".equals(Thread.currentThread().getName())) {
synchronized (lock1) {
System.out.println("a线程获得了lock1,还需要获取lock2");
synchronized (lock2) {
System.out.println("a线程获得了lock1和lock2");
}
}
}
if ("b".equals(Thread.currentThread().getName())) {
synchronized (lock2) {
System.out.println("b线程获得了lock2,还需要获取lock1");
synchronized (lock1) {
System.out.println("b线程获得了lock1和lock2");
}
}
}
}
}
}

输出结果:

a线程获得了lock1,还需要获取lock2
b线程获得了lock2,还需要获取lock1

上面的例子出现了死锁,线程a获得了lock1,还需要lock2,而线程b获得了lock2还需要lock1,彼此都需要对方占用的资源,由于没有获得所有资源导致获得的锁也不会释放,造成死锁。

若使线程a和线程b获取锁的顺序一致则不会出现死锁

public class DeadLock {
public static void main(String[] args) {
SubThread thread1 = new SubThread();
thread1.setName("a");
thread1.start(); SubThread thread2 = new SubThread();
thread2.setName("b");
thread2.start();
}
static class SubThread extends Thread{
public static final Object lock1 = new Object();
public static final Object lock2 = new Object(); @Override
public void run() {
if ("a".equals(Thread.currentThread().getName())) {
//线程a先获取lock1再获取lock2
synchronized (lock1) {
System.out.println("a线程获得了lock1,还需要获取lock2");
synchronized (lock2) {
System.out.println("a线程获得了lock1和lock2");
}
}
}
if ("b".equals(Thread.currentThread().getName())) {
//线程b先获取lock1再获取lock2
synchronized (lock1) {
System.out.println("b线程获得了lock2,还需要获取lock1");
synchronized (lock2) {
System.out.println("b线程获得了lock1和lock2");
}
}
}
}
}
}

输出结果

a线程获得了lock1,还需要获取lock2
a线程获得了lock1和lock2
b线程获得了lock2,还需要获取lock1
b线程获得了lock1和lock2

轻量级同步机制volative

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

  1. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,新值对其他线程来说是立即可见的。
  2. 禁止进行指令重排序。

保证可见性

1、什么是可见性?

在说volatile保证可见性之前,先来说说什么叫可见性。谈到可见性,又不得不说JMM(java memory model)内存模型。JMM内存模型是逻辑上的划分,及并不是真实存在。Java线程之间的通信就由JMM控制。JMM的抽象示意图如下:

Java多线程 | 02 | 线程同步机制

如上图所示,我们定义的共享变量,是存储在主内存中的,也就是计算机的内存条中。线程A去操作共享变量的时候,并不能直接操作主内存中的值,而是将主内存中的值拷贝回自己的高速缓存中,修改后写入到高速缓存,并没有立即将值刷回到主存中,导致其余线程读取的是修改之前的值

public class VolatileTest {
public static void main(String[] args) {
MyData myData = new MyData();
new Thread("AAA") {
@Override
public void run() {
try {
Thread.sleep(3000);
// 睡3秒后调用changeNumber方法将number改为60
System.err.println(Thread.currentThread().getName()
+ " update number to " + myData.changeNumber());
} catch (InterruptedException e) {
e.printStackTrace();
}
};
}.start();
// 主线程
while (myData.number == 0) {
}
// 如果主线程读取到的一直都是最开始的0,
//将造成死循环,这句话将无法输出
System.err.println(Thread.currentThread().getName()
+ " get number value is " + myData.number);
}
} // 验证可见性
class MyData {
// int number = 0; // 没加volatile关键字
volatile int number = 0;
int changeNumber() {
return this.number = 60;
}
}

输出结果:

//不加volatile关键字
AAA update number to 60 //加了volatile关键字
AAA update number to 60
main get number value is 60

那么为什么可以保证可见性呢?

volatile的原理和实现机制

 前面讲述了源于volatile关键字的一些使用,下面我们来探讨一下volatile到底如何保证可见性和禁止指令重排序的。

  下面这段话摘自《深入理解Java虚拟机》:

  “观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

  lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  2. 它会强制将对缓存的修改操作立即写入主存;
  3. 如果是写操作,它会导致其他CPU中对应的缓存行无效。

不保证原子性

1、什么叫原子性?

所谓原子性,就是说一个操作不可被分割或加塞,要么全部执行,要么全不执行。

public class Test {
public volatile int inc = 0; public void increase() {
inc++;
} public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
} while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}

大家想一下这段程序的输出结果是多少?也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。

2、volatile不保证原子性解析

java程序在运行时,JVM将java文件编译成了class文件。我们使用javap命令对class文件进行反汇编,就可以查看到java编译器生成的字节码。最常见的 i++ 问题,其实 反汇编后是分三步进行的。

  • 第一步:将i的初始值装载进工作内存;
  • 第二步:在自己的工资内存中进行自增操作;
  • 第三步:将自己工作内存的值刷回到主内存。

我们知道线程的执行具有随机性,假设现在i的初始值为0,有A和B两个线程对其进行++操作。首先两个线程将0拷贝到自己工作内存,当线程A在自己工作内存中进行了自增变成了1,还没来得及把1刷回到主内存,这是B线程抢到CPU执行权了。B将自己工作内存中的0进行自增,也变成了1。然后线程A将1刷回主内存,主内存此时变成了1,然后B也将1刷回主内存,主内存中的值还是1么两个线程分别进行了一次自增操作后,inc只增加了1,出现了写丢失的情况。这是因为i++本应该是一个原子操作,但是却被加塞了其他操作。所以说volatile不保证原子性。

volatile能保证有序性吗

什么叫指令重排?

使用javap命令可以对class文件进行反汇编,查看到程序底层到底是如何执行的。像 i++ 这样一个简单的操作,底层就分三步执行。在多线程情况下,计算机为了提高执行效率,就会对这些步骤进行重排序,这就叫指令重排

在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。

  volatile关键字禁止指令重排序有两层意思:

  1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

  2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

  可能上面说的比较绕,举个简单的例子:

//x、y为非volatile变量
//flag为volatile变量 x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5

  由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。

  并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

  那么我们回到前面举的一个例子:

//线程1:
context = loadContext(); //语句1
inited = true; //语句2 //线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

这个例子有可能语句2会在语句1之前执行,就可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。

  这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕。

使用volatile关键字的场景

​ synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:

  • 对变量的写操作不依赖于当前值

  • 该变量没有包含在具有其他变量的不变式中

  实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

  事实上,我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

  下面列举几个Java中使用volatile的几个场景。

1.状态标记量

volatile boolean flag = false;

while(!flag){
doSomething();
} public void setFlag() {
flag = true;
}
volatile boolean inited = false;
//线程1:
context = loadContext();
inited = true; //线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

2.double check

class Singleton{
private volatile static Singleton instance = null; private Singleton() { } public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}

为什么要这样写:传送门

参考文档

https://www.cnblogs.com/dolphin0520/p/3920373.html

https://www.jianshu.com/p/b05e4da39de9

上一篇:导向矢量(Steering Vector)


下一篇:本地存储localStorage sessionStorage 以及 session 和cookie的对比和使用