文章目录
- 第一章 并发编程线程基础
- 1.什么是线程
- 2.线程的创建与运行
- 3.线程的通知与等待
- wait()
- wait(long timeout)
- wait(long timeout, int nanos)
- notify()与notifyAll()
- 虚假唤醒
- 4.等待线程执行终止的join方法
- 5.让线程睡眠的sleep方法
- 6.让CPU交出执行权的yield方法
- 7.线程中断
- 8.理解线程上下文切换
- 9.线程死锁
- 10.守护线程与用户线程
- 11.ThreadLocal
- 使用示例
- ThreadLocal的实现原理
- InheritableThreadLocal类
- 第二章 并发编程的其他基础知识
- 1.Java中的线程安全问题
- 2.Java中共享变量的内存可见性问题
- 3.Java中的synchronized关键字
- 4.Java中的volatile关键字
- 5.Java中的CAS操作
- 6.Unsafe
- Unsafe类中的重要方法
- 如何使用Unsafe类
- 7.Java指令重排序
- 8.伪共享
- 什么是伪共享
- 为什么会出现伪共享
- 如何避免伪共享
- 9.锁的概述
- 乐观锁与悲观锁
- 公平锁和非公平锁
- 独占锁与共享锁
- 可重入式锁
- 自旋锁
第一章 并发编程线程基础
1.什么是线程
线程是进程的一个实体,线程本身是不会存在的。
操作系统在分配资源的时候是把资源分配给进程的,CPU比较特殊,他是被分配给线程的,因为真正要占用CPU运行的是线程,所以说线程是CPU分配的基本单位。
进程与线程的关系如图:
2.线程的创建与运行
Java中有三种创建线程的方式:
- 实现Runnable接口的run方法
- 继承Thread类并重写run方法
- 使用FutureTask的方式
这里代码我就不赘述,需要注意的是当我们创建完线程以后,只有调用start()
方法才算真正启动了线程。其实调用了start()
方法以后,线程并没有立马执行而是处于就绪状态。只有当真正获取到CPU资源后才会进入运行状态。
学过操作系统的话,这一段话应该很好理解。
下面我们来说说三种方式各自的优缺点:
- 继承Thread类并重写run方法,在方法内获取当前线程使用
this
关键字就好,不需要使用Thread.currentThread()
方法。但是如果继承了Thread类就无法继承其他类。并且该方式没有返回值 - 实现Runnable接口后,我们仍然可以继承其他类,但是同第一种方法一样没有返回值
- 使用FutureTask的方式,可以有返回值。
展示一下FutureTask的用法:
//创建任务类,类似Runable
public static class CallerTask implements Callable<String>{
@Override
public String call() throws Exception {
return "hello";
}
}
public static void main(String[] args) throws InterruptedException { // 创建异步任务
FutureTask<String> futureTask = new FutureTask<>(new CallerTask()); //启动线程
new Thread(futureTask).start();
try {
//等待任务执行完毕,并返回结果
String result = futureTask.get();
System.out.println(result);
} catch (ExecutionException e) {
e.printStackTrace();
}
}
任务结束以后使用FutureTask
类的get
方法获得返回结果。
3.线程的通知与等待
首先说说Object类中的通知等待方法
wait()
当一个线程调用一个共享变量的wait()
方法以后会被阻塞挂起,只有发生以下几种情况之一时才会返回:
- 其他线程调用了共享对象的
notify()
或者notifyAll()
方法 - 其他线程调用了该线程的
interrupt()
方法,同时该线程会抛出InterruptException
返回
获得一个共享变量的锁有两种方式:
- 使用
synchronized
关键字 - 使用
Lock
类,不过使用Lock的话就不会用wait()
了
注意:
-
如果某一个线程执行
wait()
方法没有获得该共享变量的锁,就会抛出IllegalMonitorException
异常。 -
如果 一个线程调用共享资源的对象的
wait()
方法以后进入阻塞状态,如果其他线程中断了该线程,则该线程会抛出InterruptException
异常并返回; -
当我们调用
wait()
方法后,线程就会释放共享资源对象的锁,但是并不会释放其他共享对象上的锁。
wait(long timeout)
该方法多了参数,他的不用之处在于,如果一个线程调用了共享资源对象的这个方法以后进入阻塞状态,如果在指定的timeout
ms时间里被notify()
或者notifyAll()
方法唤醒,就会因为超时而返回。如果timeout的传参为负数,会直接抛出IllegalArgumentException
异常。
wait(long timeout, int nanos)
该方法内部调用的是wait(long timeout)
方法,只有当nanos大于0时,会使参数timeout递增1,否则会抛出异常。
notify()与notifyAll()
这两个方法分别会唤醒一个和所有因为调用某个共享资源变量进入阻塞状态的线程。
被唤醒的线程并不会立马从wait()
方法返回并执行,他必须在获取了共享变量的锁以后才可以返回。
虚假唤醒
说到这里就得提一下虚假唤醒了。
所谓的虚假唤醒,就是一个线程可以从挂起状态变为可以运行状态(也就是被唤醒), 即使该线程没有被其他线程调用 notify()、notifyAll() 方法进行通知,或者被中断,或者等待超时,这就是所谓的虚假唤醒。
如果一个线程超时等待它是会自动被唤醒的!为了避免超时等待要把wait放在循环中,这样等待的线程如果因为超时等待被唤醒,会再次判断是否符合唤醒条件,不符合的话就再次进入等待状态,符合的话就进入运行态。
4.等待线程执行终止的join方法
join()
方法是Thread
提供的方法。
比如说我们在主线程中调用其他两个子线程的join()
方法,那么主线程会进入阻塞状态,等待两个子线程执行完返回
5.让线程睡眠的sleep方法
sleep(long mills)
这个方法是Thread中的静态方法。
当一个线程执行该方法以后会让出指定时间的执行权,也就是在这期间不参与CPU的调度,但是该线程所持有的锁并不会释放。
如果其他线程调用了某个正在执行sleep()方法的线程的interrupt的方法,这个线程会在调用sleep()方法的地方抛出InterruptException
异常并返回
6.让CPU交出执行权的yield方法
yield()
该方法也是Thread中的静态方法。
当一个线程调用了该方法,是在告诉调度器自己所占用的时间片还没用完的那一部分不想用了,暗示线程调用器进行下一轮的线程调度。
这个方法很少使用,yield()
与sleep()
的区别是:后者会使调用线程进入挂起状态,后者则不会,仍然处于就绪状态。
7.线程中断
通过设置线程的中断标志并不能直接终止该线程的执行,而是被中断的线程自行处理。
void interrupt()
:中断线程- 例如:例如,当线程 A 运行时,线程 B 可以调用线程 A 的 interrupt() 方法来设置线程 A 的中断标志为 true 并立即返回。设置标志仅仅是设 置标志,线程 A 实际并没有被中断,它会继续往下执行。如果线程 A 因为调用了 wait 系列函数、join 方法或者 sleep 方法而被阻塞挂起,这时候若线程 B 调用线程 A 的 interrupt() 方法,线程 A 会在调用这些方法的地方抛出 InterruptedException 异 常而返回。
boolean isInterrupted()
:检测当前线程是否被中断,如果是返回true,否则返回false。boolean interrupted()
:检测当前线程是否被中断,如果是返回true,否则返回false。与 isInterrupted 不同的是,该方法如果发现当前线程被中断,则会清除 中断标志,并且该方法是 static 方法,可以通过 Thread 类直接调用。- 注意:这两个方法返回的是当前线程是否被中断!而不是调用该方法的线程是否中断!比如说我们在主线程中调用子线程的interrupted方法,就会返回主线程的中断状态!在主线程中调用子线程的interrupted方法和Thread.interrupted()方法效果是一样的。
8.理解线程上下文切换
当前线程使用完时间片后,就会处于就绪状态并让出 CPU 让其他线程占用,这就是上下文切换, 从当前线程的上下文切换到了其他线程。
线程上下文切换的时机有:
- 当前线程的CPU时间片使用完处于就绪状态时
- 当前线程被其他线程中断时
9.线程死锁
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象, 在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。例如A线程持有资源1,请求资源2,B线程持有资源2,请求资源1。他们相互等待对方释放资源就造成死锁状态。
死锁产生的四个条件:
- 互斥条件:指线程对已经获取到的资源进行排它性使用,即该资源同时只由一个线 程占用。如果此时还有其他线程请求获取该资源,则请求者只能等待,直至占有资 源的线程释放该资源。
- 请求并持有条件:指一个线程已经持有了至少一个资源,但又提出了新的资源请求, 而新资源已被其他线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自己 已经获取的资源。
- 不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其他线程抢占,只有 在自己使用完毕后才由自己释放该资源。
- 环路等待条件:指在发生死锁时,必然存在一个线程—资源的环形链,即线程集合 {T0,T1,T2,…,Tn} 中的 T0 正在等待一个 T1 占用的资源,T1 正在等待 T2 占 用的资源,…Tn 正在等待已被 T0 占用的资源。
10.守护线程与用户线程
Java中的线程分为两类:守护线程和用户线程。守护线程是为用户线程服务的,比如垃圾回收线程。当虚拟机中只剩下守护线程时,虚拟机就会退出。
我们可以调用线程的setDaemon()
方法来设置线程为守护线程。
如果你希望在主线程结束后 JVM 进程马上结束,那么在创建线程时可以将 其设置为守护线程,如果你希望在主线程结束后子线程继续工作,等子线程结束后再让 JVM 进程结束,那么就将子线程设置为用户线程。
11.ThreadLocal
ThreadLocal 是 JDK 包提供的,它提供了线程本地变量,也就是如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本。这样做可以避免线程安全问题,但是ThreadLocal并不是为了解决这个问题而存在的。
使用示例
TheadLocal<String> local = new ThreadLocal();
Thread threadOne = new Thread(new Runnable() {
public void run() {
//设置线程One中本地变量localVariable的值
localVariable.set("threadOne local variable");
System.out.println("threadOne" + ":" +localVariable.get());
});
}
Thread threadTwo = new Thread(new Runnable() {
public void run() {
//设置线程Two中本地变量localVariable的值
localVariable.set("threadTwo local variable");
System.out.println("threadTwo" + ":" +localVariable.get());
});
}
threadOne.start();
threadTwo.start();
//最后输入结果为
//threadOne:threadOne local variable
//threadTwo:threadTwo local variable
这里线程1访问的是他本地localThread的副本,同样的线程2访问的是他本地localThread的副本。线程1无法访问到线程2本地的副本中的内容,同理线程2也是。
ThreadLocal的实现原理
在Thread类中有一个threadLocals
成员变量,还有一个inheritableThreadLocals
成员变量,他们两个的类型都是ThreadLocalMap
,这是一个定制化的HashMap
。只有当线程第一次调用ThreadLocal
的set()
或者get()
方法时才会对其初始化。
其实每个线程的本地变量不是存放在 ThreadLocal
实例里面, 而是存放在调用线程的 threadLocals 变量里面。也就是说,ThreadLocal
类型的本地变量存 放在具体的线程内存空间中。ThreadLocal
就是一个工具壳,它通过 set 方法把 value 值放 入调用线程的 threadLocals 里面并存放起来,当调用线程调用它的 get 方法时,再从当前 线程的 threadLocals
变量里面将其拿出来使用。
如果某个线程一直不终止,那么这个本地变量会一直存放在线程的threadLocals
中,当不在需要使用这个变量的时候,我们可以使用ThreadLocal的remove()
将这个变量从threadLocals
中移除。
InheritableThreadLocal类
ThreadLocal不支持继承性,也就是说同一个ThreadLocal变量在父线程中被设置值以后,在子线程中是无法获取到的。为了解决这个问题,InheritableThreadLocal
应运而生。
InheritableThreadLocal
继 承 了 ThreadLocal
, 并重写了部分方法。InheritableThreadLocal 重写了 createMap 方法,那么现在当第 一次调用 set 方法时,创建的是当前线程的 inheritableThreadLocals
变量的实例而不再是 threadLocals
。当调用 get 方法获取当前线程内部的 map 变量时,获取 的是 inheritableThreadLocals 而不再是 threadLocals。这样做的目的就是为了让InheritableThreadLocal
代替threadLocals
。
当我们创建线程时,Thread
的构造方法中会判断父线程的InheritableThreadLocal
是否为空,如果不为空就会间接赋值给本线程的InheritableThreadLocal
参数。
那么在什么情况下需要子线程可以获取父线程的 threadlocal 变量呢?情况还是蛮多的,比如子线程需要使用存放在 threadlocal 变量中的用户登录信息,再比如一些中间件需 要把统一的 id 追踪的整个调用链路记录下来。 其实子线程使用父线程中的 threadlocal 方 法有多种方式,比如创建线程时传入父线程中的变量,并将其复制到子线程中,或者在父线程中构造一个 map 作为参数传递给子线程,但是这些都改变了我们的使用习惯,所以 在这些情况下 InheritableThreadLocal 就显得比较有用。
第二章 并发编程的其他基础知识
1.Java中的线程安全问题
在说这个之前我们先来说一说什么是共享资源。所谓共享资源就是可以被多个线程持有或者访问的资源。
而线程安全问题就是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致 出现脏数据或者其他不可预见的结果的问题。
2.Java中共享变量的内存可见性问题
在此之前,我们先了解一下Java内存模型。而为什么要有Java内存模型?这个的目的简单来说是为了屏蔽硬件差异。
要注意Java内存模型是一个抽象的概念并不是真实存在的。
Java 内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存,线程读写变量时操作的是自己工作内存中的变量。
我们都知道CPU存在cache,比如一个CPU有两个核心。每一个核心都有自己的一级缓存,除此之外还有一个所有CPU核心共享的二级缓存。而Java内存模型中的本地内存指的就是一级缓存或者二级缓存或者CPU的寄存器。
那么什么是内存不可见问题呢?
比如说线程1想要修改共享变量X的值,首先获取共享变量X的值(假如这个时候X的值为0),这个时候两级缓存都没有命中,他就会去加载主内存中X的值并且存放在他自己的一级缓存中和CPU共享的二级缓存中,线程1再修改完X的值为1以后会将X的值写入两级缓存中并写到主内存中,当线程1执行完毕以后,两级缓存和主内存中X的值都是1。
这个时候线程2想要修改X的值为2,他会在二级缓存中命中,然后修改值为2并且刷新到主内存中。到此所有都是正常的。
然后这个时候线程1再想修改X的值,在一级缓存命中,并且X=1,这里就出现问题了,线程2明明把X修改为2了,但是线程1获取到的仍然是1。这就是共享变量的不可见问题。也就是线程2写入的值对线程1不可见。
3.Java中的synchronized关键字
synchronized块是Java提供的一种原子性内置锁,这些Java内置的使用者看不到的锁被称为内部锁,也叫做监视器锁。内置锁是排他锁,也就是当一个线程获取了这个锁以后,其他线程只能等待该线程释放锁以后才能获取该锁。
由于 Java 中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而 synchronized 的 使用就会导致上下文切换。
synchronized的内存语义:
这里再说一下synchronized 的一个内存语义,这个内存语义就可以解决共享变量内存可见性问题。 进入synchronized 块的内存语义是把在 synchronized 块内使用到的变量从线程的工作内存中清除,这样在 synchronized 块内使用到该变量时就不会从线程的工作内存中获取,而是 直接从主内存中获取。退出 synchronized 块的内存语义是把在 synchronized 块内对共享变 量的修改刷新到主内存。
除此之外,该关键字还常被用来实现原子性操作,不要要注意的是,使用synchronized关键字会引起上下文切换并且带来线程调度开销。
4.Java中的volatile关键字
上面说了使用锁来解决共享变量内存可见性问题,但是使用锁会引起上下文切换,太过笨重,所以Java还提供了一种弱形式的同步,就是使用volatile关键字。
当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是直接把值刷回主存,当其他线程读取该变量时,会直接从主存中重新获取最新值,而不是从一级缓存或者二级缓存中获取。
相对于synchronized关键字,volatile关键字不会造成线程的开销,但是volatile不能保证操作的原子性。
使用该关键字还可以避免指令重排,有兴趣可以自己去了解。
5.Java中的CAS操作
CAS 即 Compare and Swap,其是 JDK 提供的非阻塞原子性操作,它通过硬件保证了比较—更新操作的原子性。JDK 里面的 Unsafe 类提供了一系列的 compareAndSwap* 方法,下面以 compareAndSwapLong 方法为例进行简单介绍。
boolean compareAndSwapLong(Object obj, long valueOffset, long expect, long update)
/**
该方法具有四个操作数
Object obj:对象内存位置
long valueOffset:对象中变量的偏移量
long expect:变量预期值
long update:更新值
*/
说到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 问题的产生。
6.Unsafe
JDK 的 rt.jar 包中的 Unsafe 类提供了硬件级别的原子性操作,Unsafe 类中的方法都是 native 方法,它们使用 JNI 的方式访问本地 C++ 实现库。
Unsafe类中的重要方法
long objectFieldOffset(Field field)
返回指定的变量在所属类中的内存偏移地址, 该偏移地址仅仅在该 Unsafe 函数中访问指定字段时使用。
int arrayBaseOffset(Class arrayClass)
获取数组中第一个元素的地址。
int arrayIndexScale(Class arrayClass)
获取数组中一个元素占用的字节。
boolean compareAndSwapLong(Object obj, long offset, long expect, long update)
比较对象 obj 中偏移量为 offset 的变量的值是否与 expect 相等,相等则使用 update值更新,然后返回 true,否则返回 false。
public native long getLongvolatile(Object obj, long offset)
获取对象obj中偏移量为 offset 的变量对应 volatile 语义的值。
void putLongvolatile(Object obj, long offset, long value)
设置 obj 对象中offset偏移的类型为 long 的 field 的值为 value,支持 volatile 语义。
void putOrderedLong(Object obj, long offset, long value)
设置 obj 对象中 offset偏移地址对应的 long 型 field 的值为 value。这是一个有延迟的 putLongvolatile 方法, 并且不保证值修改对其他线程立刻可见。只有在变量使用 volatile 修饰并且预计会被意外修改时才使用该方法。
void park(boolean isAbsolute, long time)
阻塞当前线程,其中参数 isAbsolute 等于 false 且 time 等于 0 表示一直阻塞。time 大于 0 表示等待指定的 time 后阻塞线 程会被唤醒,这个 time 是个相对值,是个增量值,也就是相对当前时间累加 time 后当前线程就会被唤醒。如果 isAbsolute 等于 true,并且 time 大于 0,则表示阻塞 的线程到指定的时间点后会被唤醒,这里 time 是个绝对时间,是将某个时间点换 算为 ms 后的值。如果其他线程调用了当前阻塞线程的interrupt()
方法或者其他线程调用了unPark()
方法,线程会返回。
void unpark(Object thread)
唤醒调用 park 后阻塞的线程。
下面是 JDK8 新增的函数,这里只列出 Long 类型操作。
long getAndSetLong(Object obj, long offset, long update)
获取对象 obj 中偏移 量为 offset 的变量 volatile 语义的当前值,并设置变量 volatile 语义的值为 update。
long getAndAddLong(Object obj, long offset, long addValue)
获取对象 obj 中偏移 量为 offset 的变量 volatile 语义的当前值,并设置变量值为原始值 +addValue。
如何使用Unsafe类
如果你想使用Unsafe类,你并不能直接使用Unsafe类下的getUnsafe()
方法来获取Unsafe类的实例。如果你尝试这样做,那么程序会抛出异常。
我们来看一下源码就知道了:
private static final Unsafe theUnsafe = new Unsafe();
public static Unsafe getUnsafe(){
// getCallerClass可以获取调用该静态方法的对象的Class
Class localClass = Reflection.getCallerClass();
if (!VM.isSystemDomainLoader(localClass.getClassLoader())) { }
throw new SecurityException("Unsafe");
return theUnsafe;
}
// 判断paramClassLoader是不是BootStrap类加载器
public static boolean isSystemDomainLoader(ClassLoader paramClassLoader){
return paramClassLoader == null;
}
Unsafe类是rt.jar包提供的,这个包下的类是使用BootStrap类加载器加载的,而如果我们自定义一个类在main函数中调用getUnsafe()
方法,首先我们这个类是使用AppClassLoader类加载器加载的,根据委托机制会委托BootStrap类加载器去加载Unsafe类。
isSystemDomainLoader
这里会判断调用Unsafe静态类方法的实例对象的Class是不是使用BootStrap加载的,这样就可以避免用户去调用Unsafe类方法而又不影响系统的使用。这是JDK开发组特意做的限制。因为Unsafe类可以直接操作内存,这是不安全的。
如果你真的想使用Unsafe类,可以使用反射获取Unsafe类的theUnsafe成员变量。
7.Java指令重排序
Java 内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在 数据依赖性的指令重排序。在单线程下重排序可以保证最终执行的结果与程序顺序执行的 结果一致,但是在多线程下就会存在问题。
我们先说一个很简单的例子来说明代码之间的依赖性:
int a = 1;
int b = 2;
int c = a + b;
在上述代码中,c依赖a和b的值,所以重排序可以保证第三行代码一定在前两行之后,但是第一行代码和第二行代码就不能保证谁先谁后了。
再看一个多线程的例子:
private static int num =0;
private static boolean ready = false;
public static class ReadThread extends Thread { 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 {
public void run() {
num = 2;//(3)
ready = true;//(4)
}
}
这里我们先不谈内存可见性问题,volatile本身就可以解决内存可见性问题和避免指令重排。
这里我们同时启动ReadThread和WriteThread线程。
然后就是代码1,2,3,4的执行了,但是这里3和4的代码不具有依赖性,所以指令重排以后我们不能保证谁先执行谁后执行。
那么代码2的结果可能是0也可能是4。
为了解决指令重排导致的结果的不确定性,我们可以用volatile修饰ready。
8.伪共享
什么是伪共享
我们都知道CPU与主存之间存在多级Cache,Cache一般是被继承到CPU内部的。在Cache中,数据是按行存储的,每一行被称为一个Cache行,Cache行的大小一般为2的幂次数字节。
当CPU访问某个变量时,首先会在Cache中查找,如果有直接从中获取,没有的话就去主存中取。然后把改变量所在内存区域的一个Cache行大小的内存复制到Cache中。由于存放到Cache行中的是内存块而不是单个变量,所以可能会把多个变量存放到一个Cache行中。当多个线程同时修改一个缓存行里面的多个变量时, 由于同时只能有一个线程操作缓存行,所以相比将每个变量放到一个缓存行,性能会有所 下降,这就是伪共享。
这里再穿插一个知识点:缓存一致性协议
为了避免多个线程修改同一个cache行造成结果的不确定性,当一个线程操作一个cache行的时候会对该行加锁。
锁缓存行有一套协议叫缓存一致性协议。就是定义这个缓存行的锁如何有效。缓存一致性协议的实现很有很多比如:MESI,MSI,MOESI等。
大部分系统都会实现MESI。
MESI协议 规定每条缓存都有一个状态位(额外两位表示),该状态位可对应四种状态:
①修改态(Modified):此缓存被修改过,与主存数据不一致,为此缓存专有。
②专有态(Exclusive):此缓存与主内存一致,但是其他CPU中没有。
③共享态(Shared):此缓存与主内存一致,但也出现在其他缓存中。
④无效态(Invalid):此缓存无效,需要从主内存重新读取
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hUPXBIes-1668930649349)(《Java并发编程之美》读书笔记——第一部分(并发编程基础知识).assets/无标题.png)]
接下来我们说说伪共享会造成什么问题:
在上图中首先变量X和Y都被分别放到了一级缓存和二级缓存中,假如线程1使用CPU1对X进行更新,这个时候cache1中的内容就会由共享态转为修改态,这个时候CPU中X对应的缓存行就会失效,如果线程2这个时候想对变量X进行修改的话就只能去二级缓存中查找,这样就破环了一级缓存,众所周知一级缓存速度是远大于二级缓存的。这也说明了多个线程不能同时去修改自己所使用的CPU中的相同缓存里面的变量。
更坏的情况是,如果CPU只有一级缓存,那么会更频繁的访问主存。
为什么会出现伪共享
伪共享的产生是因为多个变量被放入了一个缓存行中,并且多个线程同时去写入缓存 行中不同的变量。那么为何多个变量会被放入一个缓存行呢?其实是因为缓存与内存交换 数据的单位就是缓存行,当 CPU 要访问的变量没有在缓存中找到时,根据程序运行的局部性原理,会把该变量所在的内存中大小为缓存行的内存放入缓存行。
long a;
long b;
long c;
long d;
如上,假设缓存行的大小是32个字节,一个long型变量所占字节为8。那么CPU访问变量a的时候,如果发现变量a没在cache中,就会把内存中的a以及靠近a的内存地址的b、c、d放入缓存行。
如果是在单线程的情况下,伪共享是对程序的执行有利的,因为cache命中率会提高,这样代码执行的就会更快。
如何避免伪共享
在JDK8之前,一般都是通过字节填充的方式来避免该问题。
public final static class FilledLong {
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6;
}
在JDK8时,官方提供了一个注解Contended
。这样上面的代码就能改写为:
@sun.misc.Contended
public final static class FilledLong {
public volatile long value = 0L;
}
需要注意的是,在默认情况下,@Contended 注解只用于 Java 核心类,比如 rt 包下的类。 如果用户类路径下的类需要使用这个注解,则需要添加 JVM 参数:-XX:-RestrictContended
。 填充的宽度默认为 128,要自定义宽度则可以设置 -XX:ContendedPaddingWidth
参数。
9.锁的概述
乐观锁与悲观锁
悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。
乐观锁是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测。
而数据库中乐观锁的使用,一般是使用一个字段version或者使用业务状态完成的。比如说一条数据第一次被插入到数据库中version = 0
,线程1想对他进行修改,那么查询条件是id = {id} and version = {version}
,线程2也想对其进行更新,但是线程1先执行完。线程1执行完毕以后version会被更新为1,然后线程2进行更新时,已经找不到id = {id} and version = {version}
的数据了,则执行失败。
乐观锁直到提交时才锁定,所以才不会产生任何死锁。
公平锁和非公平锁
根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁,公平锁表示线程获取锁 的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁。 而非公平锁则在运行时闯入,也就是先来不一定先得。
在没有公平性需求的情况下尽量使用非公平锁,因为公平锁会带来性能开销。
独占锁与共享锁
根据锁只能被单个线程持有还是能被多个线程持有,锁可以分为独占锁和共享锁。
独占锁保证任何时候都只有一个线程能得到锁,ReentrantLock 就是以独占方式实现 的。共享锁则可以同时由多个线程持有,例如 ReadWriteLock 读写锁,它允许一个资源可 以被多线程同时进行读操作。
独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读 操作并不会影响数据的一致性,而独占锁只允许在同一时间由一个线程读取数据,其他线 程必须等待当前线程释放锁才能进行读取。
共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。
可重入式锁
所谓可重入式锁,就是可以无限次数(严格来说是有限次数)地进入被该锁锁住的代码。
比如线程1获取了锁A,那么当他再次获取这个锁时,不会被阻塞。可重入式锁在内部维护了一个线程标识,用来指示该锁目前被哪个线程占用。然后关联一个计数器,一开始计数器为0,表示锁没有被任何线程占用;当一个线程获取了该锁时,计数器变为1,如果已经获取了该锁的线程再次获取锁,计数器便加1。当线程释放锁时,计数器便减1,当计数器为0时,锁里面线程标识就会被重置为null。
自旋锁
在此之前,我们先来说一下什么是内核态什么是用户态。
- 用户态和内核态是操作系统的两种运行级别,两者最大的区别就是特权级不同
- 用户态拥有最低的特权级,内核态具有较高的特权级
- 运行在用户态的程序不能直接访问操作系统内核结构和数据
- 操作系统数据都是存放于系统空间的,用户态进程的数据是存放在用户空间的,分开来存放就是为了让系统的数据和用户的数据互不干扰,保证系统的稳定性,分开存放,管理上比较方便,并且对于两部分数据的访问就可以进行控制,避免用户态程序恶意修改操作系统的数据和结构
由于 Java 中的线程是与操作系统中的线程一一对应的,所以当一个线程在获取锁(比 如独占锁)失败后,会被切换到内核状态而被挂起。当该线程获取到锁时又需要将其切换 到内核状态而唤醒该线程。而从用户状态切换到内核状态的开销是比较大的,在一定程度 上会影响并发性能。自旋锁则是,当前线程在获取锁时,如果发现锁已经被其他线程占有, 它不马上阻塞自己,在不放弃 CPU 使用权的情况下,多次尝试获取(默认次数是 10,可 以使用 -XX:PreBlockSpinsh
参数设置该值),很有可能在后面几次尝试中其他线程已经释 放了锁。如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞挂起。由此看来自 旋锁是使用 CPU 时间换取线程阻塞与调度的开销,但是很有可能这些 CPU 时间白白浪费了。