并发和并行
并发:并发是指同一个时间段内多个任务同时都在执行,并且都没有执行结束。
并行:在单位时间内多个任务同时在执行
在单CPU的时代多个任务都是并发执行的,这是因为单个 CPU同时只能执行一个任务。在单CPU时代多任务是共享一个CPU的,当一个任务占用 CPU运行时,其他任务就会被挂起,当占用CPU的任务时间片用完后,会把CPU让给其 他任务来使用,所以在单CPU时代多线程编程是没有太大意义的,并且线程间频繁的上 下文切换还会带来额外开销。
而在多线程编程实践中,线程的个数往往多于CPU的个数,所以一般都称多线程并 发编程而不是多线程并行编程。
为什么需要进行多线程并发编程
多核CPU时代的到来打破了单核CPU对多线程效能的限制。多个CPU意味着每个 线程可以使用自己的CPU运行,这减少了线程上下文切换的开销,但随着对应用系统性 能和吞吐量要求的提高,出现了处理海量数据和请求的要求,这些都对高并发编程有着迫 切的需求。
Java中的线程安全问题
线程安全问题是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致 出现脏数据或者其他不可预见的结果的问题。
在线程安全问题。最典型的就是计数器类的实现,计数变量count本身是一个共享变量, 多个线程可以对其进行递增操作,如果不使用同步措施,由于递增操作是获取一计算一保 存三步操作,因此可能导致计数不准确,如下所示例1。
假如当前count=0,在tl时刻线程A读取count值到本地变量countA。然后在t2时 刻递增countA的值为1,同时线程B读取count的值0到本地变量countB,此时countB 的值为0(因为countA的值还没有被写入主内存)。在t3时刻线程A才把countA的值1 写入主内存,至此线程A一次计数完毕,同时线程B递增CountB的值为1。在t4时刻 线程B把countB的值1写入内存,至此线程B一次计数完毕。这里先不考虑内存可见性 问题,明明是两次计数,为何最后结果是1而不是2呢?其实这就是共享变量的线程安全 问题。那么如何来解决这个问题呢?这就需要在线程访问共享变量时进行适当的同步,在 Java中最常见的是使用关键字synchronized进行同步,下面会有具体介绍。
Java中共享变量的内存可见性
谈到内存可见性,我们首先来看看在多线程下处理共享变量时Java的内存模型(内存模型介绍),如图
Java内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内 存里面的变量复制到自己的工作空间或者叫作工作内存,线程读写变量时操作的是自己工 作内存中的变量。Java内存模型是一个抽象的概念,那么在实际实现中线程的工作内存是 什么呢?请看下图
当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后 对工作内存里的变量进行处理,处理完后将变量值更新到主内存。
那么假如线程A和线程B同时处理一个共享变量,会出现什么情况?我们使用图2-5 所示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的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了, 所以返回乂= 1 ;到这里一切都是正常的,因为这时候主内存中也是X=l。然后线 程B修改X的值为2,并将其存放到线程2所在的一级Cache和共享二级Cache中, 最后更新主内存中X的值为2 ;到这里一切都是好的。
-
线程A这次又需要修改X的值,获取时一级缓存命中,并且X=l,到这里问题就 出现了,明明线程B已经把X的值修改为了 2,为何线程A获取的还是1呢?这 就是共享变量的内存不可见问题,也就是线程B写入的值对线程A不可见。
那么如何解决共享变量内存不可见问题?使用Java中的volatile关键字就可以解决这 个问题,下面会有讲解。
Java中的synchronized关键字
synchronized关键字介绍
synchronized块是Java提供的一种原子性内置锁,Java中的每个对象都可以把它当作 一个同步锁来使用,这些Java内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。
另外,由于Java中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线 程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而synchronized的 使用就会导致上下文切换。
synchronized的内存语义
前面介绍了共享变量内存可见性问题主要是由于线程的工作内存导致的,下面我们来 讲解synchronized的一个内存语义,这个内存语义就可以解决共享变量内存可见性问题。 进入synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存 中清除,这样在synchronized块内使用到该变量时就不会从线程的工作内存中获取,而是 直接从主内存中获取。退出synchronized块的内存语义是把在synchronized块内对共享变 量的修改刷新到主内存。
其实这也是加锁和释放锁的语义,当获取锁后会清空锁块内本地内存中将会被用到的 共享变量,在使用这些共享变量时从主内存进行加载,在释放锁时将本地内存中修改的共 享变量刷新到主内存。
除可以解决共享变量内存可见性问题外,synchronized经常被用来实现原子性操作。 另外请注意,synchronized关键字会引起线程上下文切换并带来线程调度开销。
Java指令重排
Java 存模 允许编译器和处 器对指令 以提 运行性能 对不存在数据依赖性的指令重 序。 可以保证最终 结果与 执行的结果一致,但是在 下就 存在问题。看一个例子。
int a = 1; (1)
int b = 2; (2)
int c= a + b; (3)
在如上代码中,变量C的值依赖a和b的值,所以重排序后能够保证(3)的操作在(2) (1)之后,但是(1) (2)谁先执行就不一定了,这在单线程下不会存在问题,因为并不 影响最终结果。
下面看一个多线程的例子。
private static int num =0;
private static boolean ready = false;
public static class ReadThread extends Thread{
@Override
public void run(){
while (!Thread.currentThread().isInterrupted()){
if(ready){//(1)
System.out.println(num+num);//(2)
}
System.out.println("read thread .. .");
}
}
}
public static class Writethread extends Thread{
@Override
public void run(){
num = 2;// (3)
ready = true;//(4)
System.out.println("writeThread set over...");
}
}
public static void main(String[] args) throws InterruptedException {
ReadThread rt = new ReadThread();
rt.start ();
Writethread wt = new Writethread();
wt.start();
Thread.sleep(10);
rt.interrupt();
System.out.println("main exit");
}
首先这段代码里面的变量没有被声明为volatile的,也没有使用任何同步措施,所以 在多线程下存在共享变量内存可见性问题。这里先不谈内存可见性问题,因为通过把变量 声明为volatile的本身就可以避免指令重排序问题。
这里先看看指令重排序会造成什么影响,如上代码在不考虑内存可见性问题的情况下 一定会输出4?答案是不一定,由于代码(1) (2) (3) (4)之间不存在依赖关系,所以 写线程的代码(3) (4)可能被重排序为先执行(4)再执行(3),那么执行(4)后,读 线程可能已经执行了(1)操作,并且在(3)执行前开始执行(2)操作,这时候输出结 果为0而不是4。
重排序在多线程下会导致非预期的程序执行结果,而使用volatile修饰ready就可以避免重排序和内存可见性问题。
写volatile变量时,可以确保volatile写之前的操作不会被编译器重排序到volatile写 之后。读volatile变量时,可以确保volatile读之后的操作不会被编译器重排序到volatile 读之前。
Java中的volatile关键字
1.使用volatile解决可见性问题
上面介绍了使用锁的方式可以解决共享变量内存可见性问题,但是使用锁太笨重,因 为它会带来线程上下文的切换开销。对于解决内存可见性问题,Java还提供了一种弱形式 的同步,也就是使用volatile关键字。该关键字可以确保对一个变量的更新对其他线程马 上可见。当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者 其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获 取最新值,而不是使用当前线程的工作内存中的值。volatile的内存语义和synchronized有 相似之处,具体来说就是,当线程写入了 volatile变量值时就等价于线程退出synchronized 同步块(把写入工作内存的变量值同步到主内存),读取volatile变量值时就相当于进入同 步块(先清空本地内存变量值,再从主内存获取最新值)。
注意:volatile虽然提供了可见性保证,但并不 保证操作的原子性。
那么一般在什么时候才使用volatile关键字呢?
•写入变量值不依赖变量的当前值时。因为如果依赖当前值,将是获取一计算一写入 三步操作,这三步操作不是原子性的,而volatile不保证原子性。
•读写变量值时没有加锁。因为加锁本身己经保证了内存可见性,这时候不需要把变 量声明为volatile的。
2.使用volatile禁止指令重排的例子
我们都知道一个经典的懒加载方式的双重判断单例模式,但是指令重排会导致该单例模式失效
public class Singleton {
private static Singleton instance = null;
private Singleton() { }
public static Singleton getInstance() {
if(instance == null) {
synchronzied(Singleton.class) {
if(instance == null) {
instance = new Singleton(); //非原子操作
}
}
}
return instance;
}
}
看似简单的一段赋值语句:instance= new Singleton(),但是很不幸它并不是一个原子操作,其实际上可以抽象为下面几条JVM指令:
memory = allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance = memory; //3:设置instance指向刚分配的内存地址
上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以jvm是可以针对它们进行指令的优化重排序的,经过重排序后如下:
memory = allocate(); //1:分配对象的内存空间
instance = memory; //3:instance指向刚分配的内存地址,此时对象还未初始化
ctorInstance(memory); //2:初始化对象
可以看到指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。
在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,然后就将其返回使用,导致出错。
volatile的实现原理
Java中的原子性操作
所谓原子性操作,是指执行一系列操作时,这些操作要么全部执行,要么全部不执行, 不存在只执行其中一部分的情况。在设计计数器时一般都先读取当前值,然后+1,再更新。 这个过程是读一改一写的过程,如果不能保证这个过程是原子性的,那么就会出现线程安 全问题。如下代码是线程不安全的,因为不能保证++value是原子性操作。
因为++value其实可以分为3个步骤:1、读取value原本的值 2、value的值+1 3、把新的value值写入内存。因为线程是基于处理器分配的时间片,当++value的三个步骤中,执行到 第二步时处理器执行权给了线程B,而线程B会读value的值。由于新的value值还没写入到内存,所以线程B读取到的value值是旧值,这就出现安全问题了。
那么如何才能保证多个操作的原子性呢?最简单的方法就是使用synchronized关键字 进行同步。
public class ThreadSafeCount {
private Long value;
public synchronized Long getCount() (
return value;
}
public synchronized void inc() {
++value;
}
}
使用synchronized关键字的确可以实现线程安全性,即内存可见性和原子性,但是 synchronized是独占锁,没有获取内部锁的线程会被阻塞掉,而这里的getCount方法只是 读操作,多个线程同时调用不会存在线程安全问题。但是加了关键字synchronized后,同 一时间就只能有一个线程可以调用,这显然大大降低了并发性。你也许会问,既然是只读 操作,那为何不去掉getCount方法上的synchronized关键字呢?其实是不能去掉的,别忘 了这里要靠synchronized来实现value的内存可见性。那么有没有更好的实现呢?答案是 肯定的,下面将讲到的在内部使用非阻塞CAS算法实现的原子性操作类AtomicLong就是 一个不错的选择。
CAS锁就是一种自旋锁。
Java中的CAS操作
在Java中,锁在并发处理中占据了一席之地,但是使用锁有一个不好的地方,就 是当一个线程没有获取到锁时会被阻塞挂起,这会导致线程上下文的切换和重新调度开 销。Java提供了非阻塞的volatile关键字来解决共享变量的可见性问题,这在一定程度 上弥补了锁带来的开销问题,但是volatile R能保证共享变量的可见性,不能解决读一 改一写等的原子性问题。CAS即Compare and Swap,其是JDK提供的非阻塞原子性操 作,它通过硬件保证了比较一更新操作的原子性。JDK里面的Unsafe类提供了一系列的 compareAndSwap*方法,下面以compareAndSwapLong方法为例进行简单介绍。
• boolean compareAndSwapLong(Object objjong valueOffset,long expect, long update)方 法:其中compareAndSwap的意思是比较并交换。CAS有四个操作数,分别为:对 象内存位置、对象中的变量的偏移量、变量预期值和新的值。其操作含义是,如果 对象obj中内存偏移量为valueOffset的变量值为expect,则使用新的值update替换 旧的值expecto这是处理器提供的一个原子性指令。
关于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问题的产生。
CAS的优缺点
优缺点
CAS相对于其他锁,不会进行内核态操作,有着一些性能的提升。但同时引入自旋,当锁竞争较大的时候,自旋次数会增多。cpu资源会消耗很高。
换句话 说,CAS+自旋适合 使用在低并发有同步数据的应用场景。
锁的分类
悲观锁和乐观锁
悲观锁
悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态
乐观锁
乐观锁 相对悲观锁来说的,它认为 据在一般情况下不会造成冲 ,所以在访问记录前不会加排它锁,而 在进行数据提 更新时-,才会正式对数据冲 与否进行检测。
公平锁和非公平锁
根据线程获取锁的抢占机制,锁可以分为公平锁和非平锁。
公平锁
表示线程获取锁的顺序是按照线 请求锁的时间早晚来决定的,也就是最早请求锁的 将最早获取到锁。
非公平锁
非公平锁 在运行时闯入,也就是先来不 定先得。
ReentrantLock 提供了公 和非公平锁的实现。
- 公平锁:
ReentrantLock pairLock =new eentrantLock(true)
- 非公平锁:
ReentrantLock pairLock =new eentrantLock(false)
。
如果构造函数不传递参数,则默认是非公平锁。在没有公平性需求的前提下尽量使非公平锁,因为公平锁会带来性能开销。
独占锁与共享锁
根据锁只能被单个线程持有还是能被 个线程共同持有,锁可以分为独占锁和共享锁。
独占锁保证任何时候都只有 个线程能得到锁, ReentrantL ock 就是以独占方式实现的,共享锁则可以同时由多个线程持有 ,例如 ReadWriteLock 锁,它允许 个资源可以被 线程同时进行读操作独占锁是 种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读操作并不会影响数据的 致性 ,而独占锁只允许在同 时间由 个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取。
共享锁则是 种乐观锁,它放宽了加锁的条件,允许 个线程同时进行读操作
可重入锁
当一个线程要获取一个被其他线程持有的独占锁时,该线程会被阻塞,那么当一个线 程再次获取它自己己经获取的锁时是否会被阻塞呢?如果不被阻塞,那么我们说该锁是可 重入的,也就是只要该线程获取了该锁,那么可以无限次数(在高级篇中我们将知道,严 格来说是有限次数)地进入被该锁锁住的代码。
下面看看可重入锁的例子:
public class Hello{
public synchronized void helloA(){
System, out .printin (''hello");
}
public synchronized void helloB (){
System.out.printin("hello B");
helloA();
}
}
在如上代码中,调用helloB方法前会先获取内置锁,然后打印输出。之后调用helloA 方法,在调用前会先去获取内置锁,如果内置锁不是可重入的,那么调用线程将会一直被 阻塞。
实际上,synchronized内部锁是可重入锁。可重入锁的原理是在锁内部维护一个线程 标示,用来标示该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器值为0, 说明该锁没有被任何线程占用。当一个线程获取了该锁时,计数器的值会变成1,这时其他线程再来获取该锁时会发现锁的所有者不是自己而被阻塞挂起。
但是当获取了该锁的线程再次获取锁时发现锁拥有者是自己,就会把计数器值加+1, 当释放锁后计数器值-1。当计数器值为0时,锁里面的线程标示被重置为null,这时候被 阻塞的线程会被唤醒来竞争获取该锁。
自旋锁
由于Java中的线程是与操作系统中的线程一一对应的,所以当一个线程在获取锁(比 如独占锁)失败后,会被切换到内核状态而被挂起。当该线程获取到锁时又需要将其切换 到内核状态而唤醒该线程。而从用户状态切换到内核状态的开销是比较大的,在一定程度 上会影响并发性能。自旋锁则是,当前线程在获取锁时,如果发现锁已经被其他线程占有, 它不马上阻塞自己,在不放弃CPU使用权的情况下,多次尝试获取(默认次数是10,可 以使用-XX:PreBlockSpinsh参数设置该值),很有可能在后面几次尝试中其他线程己经释 放了锁。如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞挂起。由此看来自 旋锁是使用CPU时间换取线程阻塞与调度的开销,但是很有可能这些CPU时间白白浪费。
小结
原子性操作、内存可见性和有序性是构成线程安全性的三个主题。
1、原子性:要实现原子性方式较多,可用synchronized、lock加锁,CAS、AtomicInteger等,但volatile关键字是无法保证原子性的;
2、可见性:要实现可见性,也可用synchronized、lock,volatile关键字可用来保证可见性;
3、有序性:要避免指令重排序,synchronized、lock作用的代码块自然是有序执行的,volatile关键字有效的禁止了指令重排序,实现了程序执行的有序性;
为什么synchronized可以保证有序性?
首先我们了解下as-if-serial语义:在Java中,不管怎么排序,都不能影响单线程程序的执行结果。这就是as-if-serial语义。
指令重排在多线程的情况下会导致有序性问题,不符合as-if-serial语义,解决有序性问题的办法,就是禁止处理器优化和指令重排,就像volatile中使用内存屏障一样。synchronized是一种排他的、可重入的锁。当某个线程执行到一段被synchronized修饰的代码之前,会先进行加锁,执行完之后再进行解锁。在加锁之后,解锁之前,其他线程是无法再次获得锁的,只有这条加锁线程可以重复获得该锁。所以,被synchronized修饰的代码是单线程执行的,有as-if-serial语义保证,单线程的有序性就天然存在了。