并发编程的其他基础知识

PunkLu 2019年08月22日 334次浏览

并发与并行

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

Java中的线程安全问题

共享资源是指该资源被多个线程所持有或者说多个线程都可以去访问该资源。

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

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

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

在双核CPU系统架构里,每个核有自己的运算器和控制器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辑运算。每个核有自己的一级缓存,
在有些架构里还有一个所有CPU共享的二级缓存。
Java内存模型里面的工作内存就对应这里的L1或者L2缓存或者CPU的寄存器。

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

假如线程A和线程B同时处理一个共享变量,且线程A和线程B使用不同CPU执行,并且当前两级Cache都为空,那么这时候由于Cache的存在,将导致内存不可见问题:
1、线程A首先获取共享变量X的值,由于两级Cache都没有命中,所以加载主内存中X的值,假如为0.然后把X=0的值缓存到两级缓存,线程A修改X的值为1,
然后将其写入两级缓存,并且刷新到主内存。线程A操作完毕后,线程A所在的两级Cache内和主内存里面X的值都是1。
2、线程B获取X的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了,所以返回X=1;到这里一切都是正常的,因为这时候主内存中也是X=1.
然后线程B修改X的值为2,并将其存放到线程2所在的一级Cache和共享耳机Cache,
最后更新主内存中X的值为2;到这里一切都是好的。
3、线程A这次又需要修改X的值,获取时一级缓存命中,并且X=1,到这里问题就出现了,明明线程B已经把X的值修改为了2,线程A获取的还是1,
这就是共享变量的内存不可见问题,也就是线程B写入的值对线程A不可见。

使用Java中的volatile关键字就可以解决这个问题。

Java中的synchronized关键字

synchronized块是Java提供的一种原子性内置锁,Java中的每个对象都可以把它当作一个同步锁来使用,这些Java内置的使用者看不到的锁被称为内部锁,
也叫作监视器锁。线程的执行代码在进入synchronized代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。
拿到内部锁的线程会在正常退出代码块或者抛出异常后或者在同步块内调用了该内置锁资源的wait系列方法时释放该内置锁。内置锁是排它锁,
也就是当一个线程获取这个锁后,其他线程必须等待该线程释放该锁后才能获取该锁。

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

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

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

除可以解决共享变量内存可见性问题外,synchronized经常被用来实现原子性操作。

Java中的volitile关键字

使用锁的方式可以解决共享变量内存可见性问题,但是使用锁太笨重,因为它会带来线程上下文的切换开销。对于解决内存可见性问题,
Java还提供了一种弱形式的同步,就是使用volatile关键字。
该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为volatile时,线程在写入变量时不会把值缓存到寄存器或者其他地方,
而是把值刷新回主内存,当其他线程读取该共享变量时,
会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。volatile的内存语义和synchronized有相似之处,具体来说就是,
当线程写入了volatile变量值时就等价于线程退出synchronized同步块(把写入工作内存的变量值同步到主内存),
读取volatile变量值时就相当于进入同步块(先清空本地内存变量值,再从主内存获取最新值)。

有关Volatile的示例VolatileDemo和SynchronizedDemo以及VolatileSafeDemo。其中后两者都解决了内存可见性问题,但synchronized是独占锁,
同时只能有一个线程调用,其他调用线程会被阻塞,且存在线程上下文切换和线程重新调度的开销,这也是使用锁方式不好的地方,
而volatile是非阻塞算法,不会造成线程上下文切换的开销。

volatile只保证了可见性,并不保证操作的原子性。

使用volatile的场景:
1、写入变量值不依赖变量的当前值时。如果依赖当前值,将是获取-计算-写入三步操作,这三步操作不是原子性的,而volatile不保证原子性。
2、读写变量值时没有加锁。因为加锁本身已经保证了内存可见性。

Java中的原子性操作

所谓原子性操作,是指一系列操作执行时,这些操作要么全部执行,要么全部不执行,不存在只执行其中一部分的情况。
ThreadNotSafeCount类就是线程不安全的例子,因为++value不是原子操作。

最简单的保证多个操作的原子性的方法就是synchronized关键字。但synchronized是独占锁,而ThreadNotSafeCount类类里的getCount方法只是读操作,
多个线程同时调用不会存在线程安全问题。但是加了synchronized后,只能有一个线程调用该方法,降低了并发性。
而且getCount方法上的synchronized关键字不能去掉,因为要靠synchronized来实现value的内存可见性。在这种情况下,
在内部使用非阻塞CAS算法实现的原子性操作类AtomicLong是很好的替代选择。

Java中的CAS操作

CAS即Compare and Swap,其是JDK提供的非阻塞原子性操作,它通过硬件保证了比较-更新操作的原子性。

Java指令重排序

Java内存模型允许编译器和处理器对指令重排序以提高允许性能,并且只会对不存在数据依赖性的指令重排序。
在单线程下重排序可以保证最终执行的结果与程序顺序执行的结果一致,但是在多线程下就会存在问题。

一个例子:
int a = 1;
int b = 2;
int c = a + b;

在如上代码中,变量c的值依赖a和b的值,所以重排序后能保证第三步的操作在1、2之后,但是1、2谁先执行就不一定了,
这在单线程下不会出问题,因为并不影响最终结果。但在多线程环境下不是。
有关重排序的代码见ResortDemo。

重排序在多线程下会导致非预期的程序执行结果,而使用volatile修饰就可以避免重排序和内存可见性问题。

写volatile变量时,可以确保volatile写之前的操作不会被编译器重排序到volatile写之后。
读volatile变量时,可以确保volatile读之后的操作不会被编译器重排序到volatile读之前。

锁的概述

乐观锁与悲观锁

悲观锁是指对数据被外界修改持保守态度,认为数据很容易被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。悲观锁的实现往往依靠数据库提供的锁机制,
即在数据库中,在对数据记录操作前给记录加排它锁。如果获取失败,则说明数据正在被其他线程修改,当前线程则等待或者抛出异常。如果获取锁成功,则对记录进行操作,然后提交事务后释放排它锁。