并发编程的基础知识(一)

1 什么是多线程并发

笔者首先阐述两个概念,什么是并行和并发。
并发是指同一时间段内多个任务同时都在执行,并且都没有执行结束。
并行是在单位时间内多个任务同时在执行。

并发强调在一个时间段内同时执行,而一个时间段由多个单位时间累积而成,所以并发的多个任务在单位时间内不一定同时在执行。
在单CPU的时代多任务是并发执行的,这是因为单个CPU同一时间只能执行一个任务。在单CPU时代,多任务是共享一个CPU的,当一个任务占用CPU运行时,其他任务就会被挂起,当占用CPU的任务时间片用完之后,会把CPU让给其他任务来使用,此外线程间频繁的上下文切换会给系统带来额外的开销。
单个CPU上运行两个线程
如图1-1所示,在某个时间内单CPU只执行一个线程上面的任务。当线程A的时间片用完后会进行线程的上下文切换,也就是保存当前线程的执行上下文,然后切换到线程B来占用CPU运行任务。

两个线程在两个CPU上运行
图1-2所示两个CPU配置,线程A和线程B各自在自己的CPU上执行任务,实现了真正的并行运行。
实际情况中,线程的个数往往多余CPU的个数,所以称为多线程成并发而不是多线程并行。

2 Java中的线程安全问题

很多人都知道,在高并发系统中存在线程安全问题,线程安全问题指得到底是什么?读者可以问问自己这个问题,能表达清楚吗?
言归正传,谈到线程安全问题,一定要说共享资源。共享资源,就是该资源可以被多个线程去访问。
线程安全问题指得是当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或其他不可预见的问题。
线程安全问题
在图2-1中,线程A和线程B可以同时操作主内存的共享变量,那么线程安全问题是怎么产生的?如果每个线程都只是读取共享资源,而不去修改,就不会产生线程安全问题。只有当至少一个线程修改共享资源时才会存在线程安全问题。
线程安全问题,最典型的就是计数器类的实现,计数变量count本身就是一个共享变量,多个线程可以对其进行递增操作,如果不使用同步措施,由于递增操作是三个步骤,获取-计算-保存,因此可能导致计数不准确。

线程(时间) t1 t2 t3 t4
线程A 从内存中读取count的值到本线程 递增本线程count的值 写会主内存
线程B 从内存中读取count的值到本线程 递增本线程count的值 写会主内存

假如当前count=0,在t1时刻线程A读取count值到本地变量countA,然后在t2时刻递增countA的值为1,同时线程B读取count到本地变量countB,此时countB的值为0(因为countA的值还没有写入主内存)。在t3时刻线程A才把countA的值1写入主内存,至此线程A一次计数完毕,同时线程B递增countB的值为1。在t4时刻,线程B把countB的值1写入内存,至此线程B的一次计数完毕。明明是两次计数,为什么最后结果是1不是2呢?其实这就是共享变量的线程安全问题。
那么如何解决上述问题呢?这就需要在线程访问共享变量时进行适当的同步,在Java中最常见的是使用关键字synchronized进行同步。

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

谈到内存的可见性,我们首先来看看在多线程下处理共享变量时Java的内存模型。
抽象的Java内存模型
Java内存模型规定,将所有变量都存放在主内存里面,当线程使用变量的时候,会把主内存里面的变量复制到自己的工作空间或者工作内存,线程读写变量时操作的是自己的工作内存中的变量,入图3-1所示,这是一个抽象的Java内存模型。

实际线程中的工作内存
图3-2所示是一个双核CPU系统架构,每个核都有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辑运算。每个核都有自己的一级缓存,在有些架构里面还有一个所有CPU共享的二级缓存。Java内存模型里面的工作内存,就对应L1缓存或者L2缓存或者CPU的寄存器。
当一个线程操作共享变量的时候,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里的变量进行处理,处理完之后将变量值更新到主内存。
假如线程A和线程B同时处理一个共享变量,会出现什么问题呢?使用图3-2所示的CPU架构,假设线程A和线程B使用不同的CPU执行,并且当前两级Cache都为空,那么这时候由于Cache的存在,将会导致内存不可见的问题。

  • 线程A首先获取共享变量X的值,由于两级Cache都没有命中,所以加载主内存中X的值,假如为0。然后把X的值缓存到两级Cache,线程A修改X的值为1,然后将其写入两级Cache,并且刷新到主内存。线程A操作完毕后,线程A所在的CPU的两级Cache和主内存里面的X都是1。
  • 线程B获取X的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了,所以返回X=1;到了这里一切都是正常的,因为这时候主内存也是X=1。然后线程B修改X的值为2,并将其存放到线程B所在的一级缓存和共享二级缓存Cache中,最后更新到主内存中X的值为2,到这里还是没有问题。
  • 线程A这次又需要修改X的值,获取时一级缓存命中,并且X=1,问题出现了,之前线程B已经把X的值修改为2,为什么线程A获取的还是1?这就是共享变量的内存不可见问题,即线程B写入的值对线程A不可见。

4 Java中的synchronized和volatile

synchronized是Java提供的一种原子性内置锁,Java中的每个对象都可以把它当做一个同步锁来使用,这些使用者看不到的锁被称为内部锁。线程的执行代码在进入synchronized代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。内部锁是排它锁。
以下情况会释放内部锁:

  • 正常退出同步代码块
  • 抛出异常后
  • 调用该内置锁资源的wait系列方法时

java中的线程与操作系统的原生线程是息息相关的。当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,synchronized的使用就会导致上下文的切换。

4.1 synchronized的内存定义

进入synchronized块的内存语义是把synchronized块内使用到的变量从线程的工作内存中清除,这样synchronized块内使用到该变量时就不会从线程的工作内存中获取。而是直接从主内存中获取。退出synchronized块的内存语义就是把在synchronized块内对共享变量的修改刷新到主内存。 其实这也是加锁和释放锁的语义。
synchronized经常被用来实现原子操作。synchronized的使用会引起线程上下文切换并带来线程调度开销。

4.2 volatile关键字

4.2.1 volatile保证内存的可见性

当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器,而是直接把值刷新到内存。当其他线程读取共享变量时,会从主内存获取最新值。当线程写入了volatile变量值时,会把写入工作内存的变量值同步到主内存,读取volatile变量值时,先清除本地内存变量值,再从主内存获取最新值。

4.2.1 volatile避免指令重排

Java内存模型允许编译器和处理器对指令重排来提高运行性能,并且只会对不存在数据依赖性的指令重排序。

int a = 1; (1)
int b = 2; (2)
int c = a + b; (3)

上述代码中,变量c的值依赖a和b的值,在指令重排后能够保证(3)的操作在(2)(1)之后,至于(1)(2)谁先执行就不一定了。
写volatile变量时,可以确保volatile写之前的操作不会被编译器重排到volatile写之后;
读volatile变量时,可以确保volatile读之后的操作不会被编译器重排到volatile读之前。

4.3 synchronized和volatile的异同

  • synchronized和volatile一定程度上是等价的,都解决了共享变量value的内存可见性。
  • synchronized使用排它锁,其它调用线程会被阻塞,存在线程上下文切换和线程重新调度的开销;volatile是非阻塞算法,不会造成线程上下文切换的开销。
  • synchronized是可以保证原子性的,volatile提供了可见性保证,但并不保证操作的原子性。
  • 什么情况下使用volatile关键字?
    * 写入变量值不依赖变量的当前值。如果依赖当前值,将是获取-计算-写入三步操作,这三步操作不是原子性的,volatile不保证原子性。
    * 读写变量时没有加锁。因为加锁已经保证了内存的可见性。
发布了16 篇原创文章 · 获赞 5 · 访问量 3295

猜你喜欢

转载自blog.csdn.net/qq_32573109/article/details/101284462