【Java并发编程】线程安全性详解

线程安全性

一、关于线程安全性

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的

定义来源于《Java并发编程实战》

如果正确地实现了某个对象,那么在任何操作中(包括调用对象的公有方法或者对其共有域进行读/写操作)都不会违背不变性条件或后验条件,在线程安全类的对象实例上执行的任何串行或者并行的操作都不会使对象处于无效状态。在线程安全类中封装了必要的同步机制,因此客户端无需进一步采取同步措施。

要保证线程安全,要重点下面三个概念:

  • 原子性:提供了互斥访问,同一时刻只能有一个线程来对它进行操作
  • 可见性:一个线程对主内存的修改可以及时的被其他线程观察到
  • 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序的存在,该观察结果一般杂乱无序

下面我们看一下Java为保证线程安全提供的一些类或者关键字,它们是如何处理这三个概念的。

二、Atomic包

Java从JDK1.5开始提供了java.util.concurrent.atomic包,方便程序员在多线程环境下,无锁的进行原子操作。原子变量的底层使用了处理器提供的原子指令,但是不同的CPU架构可能提供的原子指令不一样,也有可能需要某种形式的内部锁,所以该方法不能绝对保证线程不被阻塞。

在Atomic包里一共有12个类,四种原子更新方式,分别是原子更新基本类型,原子更新数组,原子更新引用和原子更新字段。Atomic包里的类基本都是使用Unsafe实现的包装类。

? 原子更新基本类型类

用于通过原子的方式更新基本类型,Atomic包提供了以下三个类:

  • AtomicBoolean:原子更新布尔类型。
  • AtomicInteger:原子更新整型。
  • AtomicLong:原子更新长整型。

? 我们首先看一下AtomicInteger

AtomicInteger的常用方法如下:

int addAndGet(int delta) :以原子方式将输入的数值与实例中的值(AtomicInteger里的value)相加,并返回结果
boolean compareAndSet(int expect, int update) :如果输入的数值等于预期值,则以原子方式将该值设置为输入的值。
int getAndIncrement():以原子方式将当前值加1,注意:这里返回的是自增前的值。
void lazySet(int newValue):最终会设置成newValue,使用lazySet设置值后,可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
int getAndSet(int newValue):以原子方式设置为newValue的值,并返回旧值。

另外两个在下面代码中体现

线程不安全的情况(没有加防护措施)

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.*;

@Slf4j
public class CountExample {

    // 请求总数
    public static int clientTotal = 5000;

    // 同时并发执行的线程总数
    public static int threadTotal = 200;

    public static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        // 定义线程池
        ExecutorService executorService = Executors.newCachedThreadPool();
        // 定义信号量 最大的线程数量
        final Semaphore semaphore = new Semaphore(threadTotal);
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);

        for (int i = 0; i < clientTotal; i++) {
            executorService.execute(() -> {
                try {
                    semaphore.acquire();
                    add();
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    log.error("exception",e);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        log.info("count:{}",count);

    }

    private static void  add() {
        count++;
    }
}

结果:
在这里插入图片描述
线程安全(以AtomicInteger为例)

package com.example.demo.example.count;

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

@Slf4j
public class CountExample2 {

    // 请求总数
    public static int clientTotal = 5000;

    // 同时并发执行的线程总数
    public static int threadTotal = 200;

    // 原子包
    public static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        // 定义线程池
        ExecutorService executorService = Executors.newCachedThreadPool();
        // 定义信号量 最大的线程数量
        final Semaphore semaphore = new Semaphore(threadTotal);
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);

        for (int i = 0; i < clientTotal; i++) {
            executorService.execute(() -> {
                try {
                    semaphore.acquire();
                    add();
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    log.error("exception",e);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        log.info("count:{}",count.get()); // get方法获取当前的值

    }

    private static void  add() {
        // 先做增加操作,再获取当前的值
        count.incrementAndGet();
        // 先获取当前的值,再做增加操作
        //count.getAndAccumulate();
    }
}

结果:
在这里插入图片描述
既然AtomicInteger的getAndAccumulate()方法能够保证线程安全,自有可取之处,下面我们看一下它的源码

public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

AtomicInteger的incrementAndGet是通过unsafe的getAndAddInt来实现的,而后者的代码如下:

public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

看一下方法的参数:

  1. 第一个参数是给定的对象
  2. 第二个参数其实是offset是对象内的偏移量(其实就是一个字段到对象头部的偏移量,通过这个偏移量可以快速定位字段)
  3. 第三个参数是期望增加的值.

看一下执行过程:

  1. 代码中首先定义一个var5的变量,这个是要设置的值
  2. 接下来开始要执行循环了, getIntVolatile方法是获取底层的值, 这个值给了var5
  3. 接下来看循环条件:compareAndSwapInt亦即CAS,是原子操作,它的作用是将指定内存地址的值(通过var1和var2获取)与所给的值相比(var5),如果相等,则将其内容替换为指令中提供的新值(var4+var5),如果不相等,则更新失败, 继续循环。

这一比较并交换的操作是原子的,不可以被中断。刚开始看,CAS也包含了读取、比较 (这也是种操作)和写入这三个操作,值得注意的是CAS是通过硬件命令保证了原子性,虽然CAS也包含了多个操作,但其的运算是固定的(就是个比较),这样的锁定性能开销很小。

从内存领域来说这是乐观锁,因为它在对共享变量更新之前会先比较当前值是否与更新前的值一致,如果是,则更新,如果不是,则无限循环执行(称为自旋),直到当前值与更新前的值一致为止,才执行更新。

简单的来说,CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则返回V。这是一种乐观锁的思路,它相信在它修改之前,没有其它线程去修改它;而Synchronized是一种悲观锁,它认为在它修改之前,一定会有其它线程去修改它,悲观锁效率很低。这里点一下,我们之后详细说Synchronized。


? 下面我们看一下AtomicLong,

AtomicLong与AtomicInteger的方法使用类似

不过JDK1.8新增的LongAdder与AtomicLong,十分相似,下面我们来看一下:

AtomicLong的形式

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

@Slf4j
public class CountExample2 {

    // 请求总数
    public static int clientTotal = 5000;

    // 同时并发执行的线程总数
    public static int threadTotal = 200;

    // 原子包
    public static AtomicLong count = new AtomicLong(0);

    public static void main(String[] args) throws InterruptedException {
        // 定义线程池
        ExecutorService executorService = Executors.newCachedThreadPool();
        // 定义信号量 最大的线程数量
        final Semaphore semaphore = new Semaphore(threadTotal);
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);

        for (int i = 0; i < clientTotal; i++) {
            executorService.execute(() -> {
                try {
                    semaphore.acquire();
                    add();
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    log.error("exception",e);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        log.info("count:{}",count.get()); // get方法获取当前的值

    }

    private static void  add() {
        // 先做增加操作,再获取当前的值
        count.incrementAndGet();
        // 先获取当前的值,再做增加操作
        //count.getAndAccumulate();
    }
}

在这里插入图片描述
LongAdder的形式

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.*;
import java.util.concurrent.atomic.LongAdder;

@Slf4j
public class CountExample {

    // 请求总数
    public static int clientTotal = 5000;

    // 同时并发执行的线程总数
    public static int threadTotal = 200;

    public static LongAdder count = new LongAdder(); // 默认值为零

    public static void main(String[] args) throws InterruptedException {
        // 定义线程池
        ExecutorService executorService = Executors.newCachedThreadPool();
        // 定义信号量 最大的线程数量
        final Semaphore semaphore = new Semaphore(threadTotal);
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);

        for (int i = 0; i < clientTotal; i++) {
            executorService.execute(() -> {
                try {
                    semaphore.acquire();
                    add();
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    log.error("exception",e);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        log.info("count:{}",count);

    }

    private static void  add() {
        count.increment();
    }
}

在这里插入图片描述
我们发现它们能实现同样的功能,但是新增的LongAdder类有着很大的意义,所提供的API基本上可以替换掉原先的AtomicLong。

与AtomicLong相比,LongAdder更多地用于收集统计数据,而不是细粒度的同步控制。在低并发环境下,两者性能很相似。但在高并发环境下,LongAdder有着明显更高的吞吐量,但是有着更高的空间复杂度(缺点就是内存占用偏高点,用空间换时间了)。

LongAdder中会维护一组(一个或多个)变量,这些变量加起来就是要以原子方式更新的long型变量。当更新方法add(long)在线程间竞争时,该组变量可以动态增长以减缓竞争。方法sum()返回当前在维持总和的变量上的总和。 (这种机制特别像分段锁机制)

对于普通类型的iDouble和 Long类型,JVM允许将64位的读或者写操作拆成两个32位的操作

LongAdder所使用的思想就是热点分离,将value值分离成一个数组,当多线程访问时,通过hash算法映射到其中的一个数字进行计数。而最终的结果,就是这些数组的求和累加。这样一来,就减小了锁的粒度。如下图所示:
在这里插入图片描述
对于AtomicLong的CAS操作,因为是在循环上进行操作,如果并发比较大,冲突会比较多,从而影响性能。

? 再看最后一个AtomicBoolean

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicBoolean;


@Slf4j
public class CountExample {

    private static AtomicBoolean isHappened = new AtomicBoolean(false);

    // 请求总数
    public static int clientTotal = 5000;

    // 同时并发执行的线程总数
    public static int threadTotal = 200;

    public static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        // 定义线程池
        ExecutorService executorService = Executors.newCachedThreadPool();
        // 定义信号量 最大的线程数量
        final Semaphore semaphore = new Semaphore(threadTotal);
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);

        for (int i = 0; i < clientTotal; i++) {
            executorService.execute(() -> {
                try {
                    semaphore.acquire();
                    test();
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    log.error("exception",e);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        log.info("isHappened:{}",isHappened.get());
    }


    private static void test() {
        if(isHappened.compareAndSet(false,true)) {  //如果为false,则设置为true
            log.info("execute");
        }

    }
}

原理与AtomicInteger相似

虽然是for循环,但是这里的代码只执行了一次,这个很有参考意义
在这里插入图片描述

? 再谈CAS

维基百科上说,在计算机科学中,比较和交换(Conmpare And Swap)是用于实现多线程同步的原子指令。 它将内存位置的内容与给定值进行比较,只有在相同的情况下,将该内存位置的内容修改为新的给定值。 这是作为单个原子操作完成的。 原子性保证新值基于最新信息计算; 如果该值在同一时间被另一个线程更新,则写入将失败。 操作结果必须说明是否进行替换; 这可以通过一个简单的布尔响应(这个变体通常称为比较和设置),或通过返回从内存位置读取的值来完成

JAVA中的CAS操作都是通过sun包下Unsafe类实现,而Unsafe类中的方法都是native方法,由JVM本地实现。

刚才仅仅演示了AtomicInteger的一个方法,但其实已经把最基本的东西说清楚了。

虽然CAS可以有效的提升并发的效率,但同时也会引入ABA问题。

第一步 定义一个变量AtomicLong value值为1
第二步 Thread1修改value为2
第三步 Thread2修改value为1
第四步 Thread3通过判断value==1,来检查value是否被修改过

所以Thread3以为value没有被修改过,但实际已经被改过两次了,这就是ABA问题。类似于链表的头在变化了两次后恢复了原值,但是不代表链表就没有变化。

所以JAVA中提供了AtomicStampedReference/AtomicMarkableReference来处理会发生ABA问题的场景,主要是在对象中额外再增加一个标记来标识对象是否有过变更。

? 原子更新引用类型
原子更新基本类型AtomicInteger,只能更新一个变量,如果要原子的更新多个变量,就需要使用这个原子更新引用类型提供的类。Atomic包提供了以下三个类:

  • AtomicReference:原子更新引用类型。
  • AtomicReferenceFieldUpdater:原子更新引用类型里的字段。
  • AtomicMarkableReference:原子更新带有标记位的引用类型。可以原子的更新一个布尔类型的标记位和引用类型。构造方法是AtomicMarkableReference(V initialRef, boolean initialMark)

? 首先演示一下AtomicReference

AtomicReference类提供了一个可以原子读写的对象引用变量。 原子意味着尝试更改相同AtomicReference的多个线程(例如,使用比较和交换操作)不会使AtomicReference最终达到不一致的状态。 AtomicReference也离不了compareAndSet()方法,它可以将引用与预期值(引用)进行比较,如果它们相等,则在AtomicReference对象内设置一个新的引用。

在这里插入图片描述
最后的结果是4

? 我们接着演示AtomicReferenceFieldUpdater

AtomicReferenceFieldUpdater被称为Java原子属性更新器,它是基于反射的工具类,用来将指定类型的指定的volatile引用字段进行原子更新,对应的原子引用字段不能是private的。通常一个类volatile成员属性获取值、设定为某个值两个操作时非原子的,若想将其变为原子的,则可通过AtomicReferenceFieldUpdater来实现。如下面例子:

注意count的值必须是valatile类型的

import lombok.Getter;
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.LongAdder;

@Slf4j
public class CountExample {

    private static AtomicIntegerFieldUpdater<CountExample> updater = new AtomicIntegerFieldUpdater.newUpdater(CountExample.class,"count");

    @Getter
    private volatile int count = 100;

    private static CountExample example = new CountExample();

    public static void main(String[] args) {
        if(updater.compareAndSet(example,100,120)) {
            log.info("update success 1,{}",example.getCount());
        }
        if(updater.compareAndSet(example,100,120)) {
            log.info("update success 2,{}",example.getCount());
        } else {
            log.info("update failed,{}",example.getCount());
        }
    }
}

结果:
在这里插入图片描述

上面AtomicReferenceFieldUpdater和AtomicReference 仍是CAS的运用

? 最后是AtomicStampReference

这个涉及到ABA问题,详细说一下AtomicStampReference:

//构造方法, 传入引用和戳
public AtomicStampedReference(V initialRef, int initialStamp)
//返回引用
public V getReference()
//返回版本戳
public int getStamp()
//如果当前引用 等于 预期值并且 当前版本戳等于预期版本戳, 将更新新的引用和新的版本戳到内存
public boolean compareAndSet(V   expectedReference,
                                 V   newReference,
                                 int expectedStamp,
                                 int newStamp)
//如果当前引用 等于 预期引用, 将更新新的版本戳到内存
public boolean attemptStamp(V expectedReference, int newStamp)
//设置当前引用的新引用和版本戳
public void set(V newReference, int newStamp) 

AtomicStampReference核心在于解决CAS引发的ABA问题,解决方式逻辑很简单,就是添加一个版本号

从CAS之前的A改成B,B改成A 变为 A版本改成B版本 B版本改成为C版本,只要变量被修改过,就要更新它的版本。

? 原子更新数组类
通过原子的方式更新数组里的某个元素,Atomic包提供了以下三个类:

  • AtomicIntegerArray:原子更新整型数组里的元素。
  • AtomicLongArray:原子更新长整型数组里的元素。
  • AtomicReferenceArray:原子更新引用类型数组里的元素。

原理与上面两组类类似,只不过对象是数组,加了索引。

? 原子更新字段类
如果我们只需要某个类里的某个字段,那么就需要使用原子更新字段类,Atomic包提供了以下三个类:

  • AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
  • AtomicLongFieldUpdater:原子更新长整型字段的更新器。
  • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于原子的更数据和数据的版本号,可以解决使用CAS进行原子更新时,可能出现的ABA问题。

原子更新字段类都是抽象类,每次使用都时候必须使用静态方法newUpdater创建一个更新器。原子更新类的字段的必须使用public volatile修饰符

原理同上一组类似


保证原子性除了使用原子包外还有锁。

三、锁

锁的种类有两种:

  • synchronized:依赖JVM,同一个时刻只能有一个线程进行操作,作用对象的作用范围内
  • Lock:依赖特殊的CPU指令,代码实现, 代表性的:ReentrantLock

锁很好体现了原子性的互斥访问。

? 首先说一下synchronized,它的作用主要有三个:

(1)确保线程互斥的访问同步代码
(2)保证共享变量的修改能够及时可见
(3)有效解决重排序问题

关于重排序:cpu对代码实现优化,不会对有依赖关系的做重排序(多线程情况下),参考【Java并发编程】CPU多级缓存最后一部分的内容
第二点可以参考【Java并发编程】Java内存模型

这个关键字。它修饰的位置不同,产生的结果可能不一样。

  • 修饰代码块:大括号括起来的代码,作用于调用的对象
  • 修饰方法:整个方法,作用于调用的对象
  • 修饰静态方法:整个静态方法,作用于所有对象
  • 修饰类:括号括起来的部分,作用于所有对象

我们看一下修饰代码块和修饰方法的情况

例子1:

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;


@Slf4j
public class Example {

    public void test1(){
        // 修饰代码块:大括号括起来的代码,作用于`调用的对象`,也就是调用这段代码的对象
        synchronized (this) {
            for (int i = 0; i < 10; i++) {
                log.info("test1 - {}",i);
            }
        }
    }
    // 修饰方法:整个方法,作用于`调用的对象`
    public synchronized void test2(){
        for (int i = 0; i < 10; i++) {
            log.info("test2 - {}",i);
        }
    }

    public static void main(String[] args) {
        Example example = new Example();
        ExecutorService executorService = Executors.newCachedThreadPool();
        executorService.execute( () -> {
            example.test1();
        });
        executorService.execute( () -> {
            example.test1();
        });
        executorService.execute( () -> {
            example.test2();
        });
        executorService.execute( () -> {
            example.test2();
        });
    }
}

结果按部就班,不冲突
在这里插入图片描述
例子2:

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;


@Slf4j
public class Example {

    public void test1(int j){
        // 修饰代码块:大括号括起来的代码,作用于`调用的对象`,也就是调用这段代码的对象
        synchronized (this) {
            for (int i = 0; i < 10; i++) {
                log.info("test1 - {} - {}",j,i);
            }
        }
    }
    // 修饰方法:整个方法,作用于`调用的对象`
    public synchronized void test2(int j){
        for (int i = 0; i < 10; i++) {
            log.info("test2 - {} - {}",j,i);
        }
    }

    public static void main(String[] args) {
        Example example = new Example();
        Example example2 = new Example();

        ExecutorService executorService = Executors.newCachedThreadPool();

        executorService.execute( ()-> {
            example.test1(1);
        });
        executorService.execute( ()-> {
            example2.test1(2);
        });
    }
}

在这里插入图片描述

我们再看一下修饰静态方法和修饰类的情况

package com.example.demo.example.count;

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;


@Slf4j
public class Example {

    public static void test1(int j){
        // 修饰类:括号括起来的部分,作用于`所有对象`
        synchronized (Example.class) {
            for (int i = 0; i < 10; i++) {
                log.info("test1 - {} - {}",j,i);
            }
        }
    }

    // 修饰静态方法:整个静态方法,作用于`所有对象`
    public static synchronized void test2(int j){
        for (int i = 0; i < 10; i++) {
            log.info("test2 - {} - {}",j,i);
        }
    }


    public static void main(String[] args) {
        Example example = new Example();
        Example example2 = new Example();
        ExecutorService executorService = Executors.newCachedThreadPool();
        executorService.execute( () -> {
            example.test1(1);
        });
        executorService.execute( () -> {
            example.test1(2);
        });
        executorService.execute( () -> {
            example.test2(1);
        });
        executorService.execute( () -> {
            example.test2(2);
        });
    }
}

结果按部就班地执行

在这里插入图片描述
在这里插入图片描述
? 我们看看这个锁的原理:

首先看看Synchronized是如何实现对代码块进行同步

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("Method 1 start");
        }
    }
}

对其进行反编译
在这里插入图片描述
关于这两条指令的作用,我们直接参考JVM规范中

monitorenter :
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

monitorexit: 
意思为:执行monitorexit的线程必须是objectf所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

通过反编译,我很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。


再来看一下同步方法的反编译结果:

public class SynchronizedMethod {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

在这里插入图片描述
从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。

JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

最后说一下JMM关于 synchronized的两条规定

  1. 线程解锁前,必须把共享变量的最新值刷新到主内存
  2. 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(注意加锁与解锁是同一把锁)

如果对JMM不熟悉,参考我这篇文章【Java并发编程】Java内存模型

? 下面看一下Lock锁

上面说了如果一个代码块被synchronized修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:
1)获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
2)线程执行发生异常,此时JVM会让线程自动释放锁。

如果这个获取锁的线程由于要等待IO或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能等待,因此很需要需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断),通过Lock就可以办到。

Lock提供了比synchronized更多的功能。但是要注意以下几点:
1)synchronized是Java语言的关键字,Lock是一个接口,通过这个接口的实现类可以实现同步访问;
2)Lock和synchronized有一点非常大的不同,采用synchronized不需要用户去手动释放锁,当synchronized方法或者synchronized代码块执行完之后,系统会自动让线程释放对锁的占用;而Lock则必须要用户去手动释放锁,如果没有主动释放锁,就有可能导致出现死锁现象。

看一下这个接口的源码

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}

说一下它的方法

  1. lock()方法是平常使用得最多的一个方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待。

  2. 前面也说了,使用Lock,必须主动去释放锁,但是如果发生异常了,不会自动释放锁。因此,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生,释放锁的方法是unlock()

  3. tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待。

  4. tryLock(long time, TimeUnit unit)方法和tryLock()方法是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。

  5. lockInterruptibly()方法比较特殊,当通过这个方法去获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态。也就使说,当两个线程同时通过lock.lockInterruptibly()想获取某个锁时,假若此时线程A获取到了锁,而线程B只有在等待,那么对线程B调用threadB.interrupt()方法能够中断线程B的等待过程。

  6. 由于lockInterruptibly()的声明中抛出了异常,所以lock.lockInterruptibly()必须放在try块中或者在调用lockInterruptibly()的方法外声明抛出InterruptedException。


我们说一下Lock的具体实现类ReentrantLock

ReentrantLock,意思是“可重入锁”,ReentrantLock是唯一实现了Lock接口的类,并且ReentrantLock提供了更多的方法。

具体介绍可以参考一下ReentrantLock的使用,这篇文章写得还是比较简单,清晰,基础的。

关于可重入锁与不可重入锁,引入一下网上比较流行的代码(找不到来源呀~)

不可重入锁
所谓不可重入锁,即若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞。我们尝试设计一个不可重入锁:

public class Lock{
    private boolean isLocked = false;
    public synchronized void lock() throws InterruptedException{
        while(isLocked){    
            wait();
        }
        isLocked = true;
    }
    public synchronized void unlock(){
        isLocked = false;
        notify();
    }
}

使用该锁:

public class Count{
    Lock lock = new Lock();
    public void print(){
        lock.lock();
        doAdd();
        lock.unlock();
    }
    public void doAdd(){
        lock.lock();
        //do something
        lock.unlock();
    }
}

当前线程执行print()方法首先获取lock,接下来执行doAdd()方法就无法执行doAdd()中的逻辑,必须先释放锁。这个例子很好的说明了不可重入锁。

可重入锁
接下来,我们设计一种可重入锁

public class Lock{
    boolean isLocked = false;
    Thread  lockedBy = null;
    int lockedCount = 0;
    public synchronized void lock()
            throws InterruptedException{
        Thread thread = Thread.currentThread();
        while(isLocked && lockedBy != thread){
            wait();
        }
        isLocked = true;
        lockedCount++;
        lockedBy = thread;
    }
    public synchronized void unlock(){
        if(Thread.currentThread() == this.lockedBy){
            lockedCount--;
            if(lockedCount == 0){
                isLocked = false;
                notify();
            }
        }
    }
}

所谓可重入,意味着线程可以进入它已经拥有的锁的同步代码块儿。

我们设计两个线程调用print()方法,第一个线程调用print()方法获取锁,进入lock()方法,由于初始lockedBy是null,所以不会进入while而挂起当前线程,而是是增量lockedCount并记录lockBy为第一个线程。接着第一个线程进入doAdd()方法,由于同一进程,所以不会进入while而挂起,接着增量lockedCount,当第二个线程尝试lock,由于isLocked=true,所以他不会获取该锁,直到第一个线程调用两次unlock()将lockCount递减为0,才将标记为isLocked设置为false。

可重入锁的概念和设计思想大体如此,Java中的可重入锁ReentrantLock设计思路也是这样

原子性一对比

  • synchronized:不可中断锁,适合竞争不激烈,可读性好
  • Lock:可中断锁,多样化同步,竞争激烈时能维持常态
  • Atomic:竟争激烈时能维持常态,比Lock性能好;缺点只能同步一个值

四、关键字 volatile

锁提供了两种主要特性:互斥(mutual exclusion) 和可见性(visibility)。互斥即一次只允许一个线程持有某个特定的锁,因此可使用该特性实现对共享数据的协调访问协议,这样,一次就只有一个线程能够使用该共享数据。可见性要更加复杂一些,它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的 —— 如果没有同步机制提供的这种可见性保证,线程看到的共享变量可能是修改前的值或不一致的值,这将引发许多严重问题。

volatile 变量具有 synchronized 的可见性特性,但是不具备原子特性。这就是说线程能够自动发现 volatile 变量的最新值(从主存中获取值)。volatile 变量能提供线程安全,但是这种能力是有限制的:多个变量之间或者某个变量的当前值与修改后值之间没有约束。

valoatile并不能保证线程安全

演示一下

package com.example.demo.example.count;

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.*;

@Slf4j
public class CountExample2 {

    // 请求总数
    public static int clientTotal = 5000;

    // 同时并发执行的线程总数
    public static int threadTotal = 200;


    public  static volatile int count = 0;

    public static void main(String[] args) throws InterruptedException {
        // 定义线程池
        ExecutorService executorService = Executors.newCachedThreadPool();
        // 定义信号量 最大的线程数量
        final Semaphore semaphore = new Semaphore(threadTotal);
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);

        for (int i = 0; i < clientTotal; i++) {
            executorService.execute(() -> {
                try {
                    semaphore.acquire();
                    add();
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    log.error("exception",e);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        log.info("count:{}",count); // get方法获取当前的值

    }

    private static void  add() {
        count++;
    }
}

在这里插入图片描述
执行add()方法的时候,其实是这样的。首先从主存中取出count值,然后进行加1,接着写回主存。并发情况下,肯定出事

使用valoatile代替synchronized锁必须满足两种条件,

  1. 对变量的写操作,不依赖当前值
  2. 该变量没有包含在具有其他变量的不变式中

volatile通过加入内存屏障和禁止重排序优化来实现

  • 对 volatile变量写操作时,会在写操作后加入一条 store屏障指令,将本地内存中的共享变量值刷新到主内存
  • 对 volatile变量读操作时,会在读操作前加入一条load屏障指令,从主内存中读取共享变量

这个涉及到Java内存模型里面的东西了,如果对JMM不熟悉,参考我这篇文章【Java并发编程】Java内存模型

可见性-volatile写
在这里插入图片描述
可见性-volatile读

在这里插入图片描述

关于有序性的保证

Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

在java里可以通过volatile来保证一定的有序性,另外也可以通过synchroized和lock来保证有序性。synchroized和lock是保证每个时刻是只有一个线程执行同步代码,相当于是让线程顺序执行代码从而保证有序性(单线程看有序,不会改变最终的结果,但是多线程情况下就不一定了)。Java具备一些先天的有序性(不需要任何手段就能保证有序性),即happens-before原则(先行发生原则)。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证有序性。虚拟机可以随意的对它们进行重排序。

除此之外,还有关于happens- before原则

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 锁定规则:一个 unLock操作先行发生于后面对同一个锁的lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

前四条规则最为重要,后面四条规则:

  • 线程启动规则:Thread对象的 start0方法先行发生于此线程的每一个动作
  • 线程中断规则:对线程 interrupt方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread join0方法结束、 Thread isAlive0的返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize0方法的开始

五、总结

线程安全性涵盖原子性、可见性、有序性。下面提取关键字,自己考虑考虑吧

原子性:Atomic包、CAS算法、 synchronized、Lock
可见性:synchronized、 volatile
有序性:happens- before

©️2020 CSDN 皮肤主题: 数字20 设计师:CSDN官方博客 返回首页