Java Review - 并发编程_前置知识二

Posted 小小工匠

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java Review - 并发编程_前置知识二相关的知识,希望对你有一定的参考价值。

文章目录


What’s 多线程并发编程

首先要澄清并发和并行的概念

  • 并发是指同一个时间段内多个任务同时都在执行,并且都没有执行结束
  • 并行是说在单位时间内多个任务同时在执行

并发任务强调在一个时间段内同时执行,而一个时间段由多个单位时间累积而成,所以说并发的多个任务在单位时间内不一定同时在执行。

在单CPU的时代多个任务都是并发执行的,这是因为单个CPU同时只能执行一个任务。在单CPU时代多任务是共享一个CPU的,当一个任务占用CPU运行时,其他任务就会被挂起,当占用CPU的任务时间片用完后,会把CPU让给其他任务来使用,所以在单CPU时代多线程编程是没有太大意义的,并且线程间频繁的上下文切换还会带来额外开销。

单个CPU上运行两个线程,线程A和线程B是轮流使用CPU进行任务处理的,也就是在某个时间内单个CPU只执行一个线程上面的任务。当线程A的时间片用完后会进行线程上下文切换,也就是保存当前线程A的执行上下文,然后切换到线程B来占用CPU运行任务。

双CPU配置,线程A和线程B各自在自己的CPU上执行任务,实现了真正的并行运行。


而在多线程编程实践中,线程的个数往往多于CPU的个数,所以一般都称多线程并发编程而不是多线程并行编程。

多核CPU时代的到来打破了单核CPU对多线程效能的限制。多个CPU意味着每个线程可以使用自己的CPU运行,这减少了线程上下文切换的开销,但随着对应用系统性能和吞吐量要求的提高,出现了处理海量数据和请求的要求,这些都对高并发编程有着迫切的需求。


线程安全问题

我们先说说什么是共享资源。所谓共享资源,就是说该资源被多个线程所持有或者说多个线程都可以去访问该资源

线程安全问题是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果的问题,如下图所示。

线程A和线程B可以同时操作主内存中的共享变量,那么线程安全问题和共享资源之间是什么关系呢

是不是说多个线程共享了资源,当它们都去访问这个共享资源时就会产生线程安全问题呢?答案是否定的,如果多个线程都只是读取共享资源,而不去修改,那么就不会存在线程安全问题,只有当至少一个线程修改共享资源时才会存在线程安全问题

举个计数器的例子

假如当前count=0

  1. 在t1时刻线程A读取count值到本地变量countA。
  2. 然后在t2时刻递增countA的值为1,同时线程B读取count的值0到本地变量countB,此时countB的值为0(因为countA的值还没有被写入主内存)。
  3. 在t3时刻线程A才把countA的值1写入主内存,至此线程A一次计数完毕,同时线程B递增CountB的值为1。
  4. 在t4时刻线程B把countB的值1写入内存,至此线程B一次计数完毕。

这里先不考虑内存可见性问题,明明是两次计数,为何最后结果是1而不是2呢?其实这就是共享变量的线程安全问题。

这就需要在线程访问共享变量时进行适当的同步,在Java中最常见的是使用关键字synchronized进行同步


共享变量的内存可见性问题

谈到内存可见性,我们首先来看看在多线程下处理共享变量时Java的内存模型


Java内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存,线程读写变量时操作的是自己工作内存中的变量。

Java内存模型是一个抽象的概念,那么在实际实现中线程的工作内存是什么呢?如下图


上中所示是一个双核CPU系统架构,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辑运算。每个核都有自己的一级缓存,在有些架构里面还有一个所有CPU都共享的二级缓存。

那么Java内存模型里面的工作内存,就对应这里的L1或者L2缓存或者CPU的寄存器。

当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里的变量进行处理 ,处理完后将变量值更新到主内存。

那么假如线程A和线程B同时处理一个共享变量,会出现什么情况?我们使用刚才的CPU架构,假设线程A和线程B使用不同CPU执行,并且当前两级Cache都为空,那么这时候由于Cache的存在,将会导致内存不可见问题,具体看下面的分析。

  • 线程A首先获取共享变量X的值,由于两级Cache都没有命中,所以加载主内存中X的值,假如为0。然后把X=0的值缓存到两级缓存,线程A修改X的值为1,然后将其写入两级Cache,并且刷新到主内存。线程A操作完毕后,线程A所在的CPU的两级Cache内和主内存里面的X的值都是1。

  • 线程B获取X的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了,所以返回X= 1;到这里一切都是正常的,因为这时候主内存中也是X=1。然后线程B修改X的值为2,并将其存放到线程2所在的一级Cache和共享二级Cache中,最后更新主内存中X的值为2;到这里一切都是好的。

  • 线程A这次又需要修改X的值,获取时一级缓存命中,并且X=1,到这里问题就出现了,明明线程B已经把X的值修改为了2,为何线程A获取的还是1呢?这就是共享变量的内存不可见问题,也就是线程B写入的值对线程A不可见。

那么如何解决共享变量内存不可见问题?使用Java中的volatile关键字就可以解决这个问题.


synchronized

synchronized块是Java提供的一种原子性内置锁,Java中的每个对象都可以把它当作一个同步锁来使用,这些Java内置的使用者看不到的锁被称为内部锁,也叫作监视器锁

线程的执行代码在进入synchronized代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的wait 系列方法时释放该内置锁。内置锁是排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。

另外,由于Java中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而synchronized的使用就会导致上下文切换。


synchronized的内存语义

共享变量内存可见性问题主要是由于线程的工作内存导致的,下面我们来看下synchronized的一个内存语义,这个内存语义就可以解决共享变量内存可见性问题

进入synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存中清除,这样在synchronized块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取退出synchronized块的内存语义是把在synchronized块内对共享变量的修改刷新到主内存

其实这也是加锁和释放锁的语义,当获取锁后会清空锁块内本地内存中将会被用到的共享变量,在使用这些共享变量时从主内存进行加载,在释放锁时将本地内存中修改的共享变量刷新到主内存。

除可以解决共享变量内存可见性问题外,synchronized经常被用来实现原子性操作。另外请注意,synchronized关键字会引起线程上下文切换并带来线程调度开销。


volatile - 解决内存可见性

上面介绍了使用锁的方式可以解决共享变量内存可见性问题,但是使用锁太笨重,因为它会带来线程上下文的切换开销。

对于解决内存可见性问题,Java还提供了一种弱形式的同步,也就是使用volatile关键字

该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。

volatile的内存语义和synchronized有相似之处,具体来说就是,当线程写入了volatile变量值时就等价于线程退出synchronized同步块(把写入工作内存的变量值同步到主内存),读取volatile变量值时就相当于进入同步块(先清空本地内存变量值,再从主内存获取最新值)。

下面看一个使用volatile关键字解决内存可见性问题的例子。如下代码中的共享变量value是线程不安全的,因为这里没有使用适当的同步措施

/**
 * @author 小工匠
 * @version 1.0
 * @description: TODO
 * @date 2021/11/27 10:23
 * @mark: show me the code , change the world
 */
public class ShareVariableTest 

    private int count ;


    public int getCount() 
        return count;
    

    public void setCount(int count) 
        this.count = count;
    

    

首先来看使用synchronized关键字进行同步的方式。

public class ShareVariableTest 

    private  int count ;


    public synchronized  int getCount() 
        return count;
    

    public synchronized void setCount(int count) 
        this.count = count;
    



然后是使用volatile进行同步。

public class ShareVariableTest 

    private volatile int count ;


    public int getCount() 
        return count;
    

    public void setCount(int count) 
        this.count = count;
    



在这里使用synchronized和使用volatile是等价的,都解决了共享变量value的内存可见性问题

  • 但是synchronized是独占锁,同时只能有一个线程调用get()方法,其他调用线程会被阻塞,同时会存在线程上下文切换和线程重新调度的开销,这也是使用锁方式不好的地方。
  • volatile是非阻塞算法,不会造成线程上下文切换的开销。

但并非在所有情况下使用它们都是等价的,volatile虽然提供了可见性保证,但并不保证操作的原子性。


一般在什么时候才使用volatile关键字

  • 写入变量值不依赖变量的当前值时。因为如果依赖当前值,将是获取—计算—写入三步操作,这三步操作不是原子性的,而volatile不保证原子性。

  • 读写变量值时没有加锁。因为加锁本身已经保证了内存可见性,这时候不需要把变量声明为volatile的。


原子性操作

所谓原子性操作,是指执行一系列操作时,这些操作要么全部执行,要么全部不执行,不存在只执行其中一部分的情况。

举个例子 在设计计数器时一般都先读取当前值,然后+1,再更新。这个过程是读—改—写的过程,如果不能保证这个过程是原子性的,那么就会出现线程安全问题。如下代码是线程不安全的,因为不能保证++value是原子性操作。

public class ShareVariableTest 

    private int count;

    public int getCount() 
        return count;
    

    public void add() 
        count++;
    

    

Javap -c 命令查看汇编代码

或者直接借助IDEA

由此可见,简单的++value由2、5、6、7四步组成,

  • 其中第2步是获取当前value的值并放入栈顶,
  • 第5步把常量1放入栈顶,
  • 第6步把当前栈顶中两个值相加并把结果放入栈顶,
  • 第7步则把栈顶的结果赋给value变量。

因此,Java中简单的一句++value被转换为汇编后就不具有原子性了。

那么如何才能保证多个操作的原子性呢?最简单的方法就是使用synchronized关键字进行同步,修改代码如下

public class ShareVariableTest 

    private int count;

    public synchronized int getCount() 
        return count;
    

    public synchronized  void add() 
        count++;
    

使用synchronized关键字的确可以实现线程安全性,即内存可见性和原子性,但是synchronized是独占锁,没有获取内部锁的线程会被阻塞掉,而这里的getCount方法只是读操作,多个线程同时调用不会存在线程安全问题。但是加了关键字synchronized后,同一时间就只能有一个线程可以调用,这显然大大降低了并发性。

既然getCount是只读操作,那为何不去掉getCount方法上的synchronized关键字呢?

其实是不能去掉的,别忘了这里要靠synchronized来实现value的内存可见性

那么有没有更好的实现呢?答案是肯定的,下面将讲到的在内部使用非阻塞CAS算法实现的原子性操作类AtomicInteger就是一个不错的选择。


CAS操作

在Java中,锁在并发处理中占据了一席之地,但是使用锁有一个不好的地方,就是当一个线程没有获取到锁时会被阻塞挂起,这会导致线程上下文的切换和重新调度开销。

Java提供了非阻塞的volatile关键字来解决共享变量的可见性问题,这在一定程度上弥补了锁带来的开销问题,但是volatile只能保证共享变量的可见性,不能解决读—改—写等的原子性问题。

CAS 即Compare and Swap,其是JDK提供的非阻塞原子性操作,它通过硬件保证了比较—更新操作的原子性。JDK里面的Unsafe类提供了一系列的compareAndSwap*方法

下面以compareAndSwapLong方法为例进行简单介绍

    public final native boolean compareAndSwapLong(Object obj,long valueOffset,long expect, long update);

compareAndSwap的意思是比较并交换

CAS有四个操作数,分别为:对象内存位置、对象中的变量的偏移量、变量预期值和新的值。

其操作含义是,如果对象obj中内存偏移量为valueOffset的变量值为expect,则使用新的值update替换旧的值expect。这是处理器提供的一个原子性指令

ABA —>解决办法AtomicStampedReference

关于CAS操作有个经典的ABA问题,具体如下

假如线程I使用CAS修改初始值为A的变量X,那么线程I会首先去获取当前变量X的值(为A), 然后使用CAS操作尝试修改X的值为B,如果使用CAS操作成功了,那么程序运行一定是正确的吗?其实未必,这是因为有可能在线程I获取变量X的值A后,在执行CAS前,线程II使用CAS修改了变量X的值为B,然后又使用CAS修改了变量X 的值为A。所以虽然线程I执行CAS时X的值是A,但是这个A已经不是线程I获取时的A了。这就是ABA问题。

ABA问题的产生是因为变量的状态值产生了环形转换,就是变量的值可以从A到B,然后再从B到A。如果变量的值只能朝着一个方向转换,比如A到B,B到C,不构成环形,就不会存在问题。JDK中的AtomicStampedReference类给每个变量的状态值都配备了一个时间戳,从而避免了ABA问题的产生。

以上是关于Java Review - 并发编程_前置知识二的主要内容,如果未能解决你的问题,请参考以下文章

Java Review - 并发编程_LockSupport

Java Review - 并发编程_Unsafe

Java Review - 并发编程_Unsafe

Java Review - 并发编程_抽象同步队列AQS

Java Review - 并发编程_ 回环屏障CyclicBarrier原理&源码剖析

Java Review - 并发编程_ThreadPoolExecutor原理&源码剖析