- 第二章 并发编程的其他基础知识
2.1 并发和并行
-
并发是指同一个时间段内多个任务同时都在执行,并且都没有执行结束,即单核CPU多任务抢占CPU执行时间
-
并行是单位时间内多个任务同时执行,即多核CPU多个任务各自在自己的CPU上同时执行
2.2 Java中的线程安全问题
共享资源
:该资源被多个线程持有或多个线程都可以去访问该资源
线程安全问题是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或其他不可预见的结果的问题
2.3 Java中共享变量的内存可见性问题
-
Java规定所有变量都放在主内存,当线程使用共享变量时,会把主内存的变量复制到自己工作内存,线程读写操作的是自己的工作内存
-
所谓共享变量内存可见性问题,当线程A,B都需要对共享变量value进行读写时,线程B写入的值对线程A不生效(Cache的存在导致内存不可见问题)
-
解决方案:共享变量使用
volatile
关键字修饰
2.4 Java中的synchronized关键字
synchronized关键字介绍
- synchronized块是Java提供的一种原子性内置锁(监视器锁)
- 线程的执行代码在进入synchronized代码块之前会自动获取监视器锁,这时其他线程访问该同步代码块会被阻塞挂起
- 监视器锁是排他锁
- Java线程是与操作系统的原声线程一一对应,当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,很耗时,synchronized的使用就会导致上下文切换
synchronized关键字的内存语义
-
解决共享变量内存可见性问题
-
原子性操作,注意synchronized关键字会引起线程上下文切换并带来线程调度开销
-
进入
synchronized
块的内存语义是把在synchronized
块内使用到的变量从线程的工作内存中清除,这样synchronized
块在用到该变量时就必须从主内存中获取 -
退出
synchronized
块的内存语义是把在synchronized
块内对共享变量的修改刷新到主内存 -
加锁语义
- 获取锁后会清空锁块内本地内存将会用到的共享变量,在使用这些共享变量时从主内存加载(synchronized块进入)
-
释放锁
- 将本地内存中修改的共享变量刷新到主内存(synchronized块退出)
2.5 Java中的volatile关键字
-
使用锁方式解决共享变量内存可见性问题,但是带来额外性能开销:锁太笨重,线程上下文切换开销
-
弱形式同步:volatile关键字,可以确保对一个变量的更新对其他线程马上可见
语义
- 当一个变量使用volatile关键字修饰时,线程写入变量值时不会把值缓存在寄存器或其他地方,而是直接把值刷新到主内存
- 其他线程读取变量时,会从内存重新获取最新值,而不是使用当前线程的工作内存的值
synchronized和volatile并非完全等价,因为volatile不保证操作的原子性
使用volatile的场合
- 写入变量值不依赖变量的当前值。如果依赖当前值,将会是 读取–>计算—>写入三步操作,这三步操作不是原子性,volatile不保证原子性
- 读写变量值时没有加锁。因为加锁本身已经保证内存可见性,这时候不需要把变量声明为volatile
2.6 Java中的原子性操作和CAS操作
原子性操作
- 所谓原子性操作,是指执行一系列操作时,要么全部执行,要么全部不执行,不存在只执行一部分的情况
- ++i变量自增、自减操作不是原子性
翻译成汇编
getfield # 2 获取当前值并放入栈顶
lconst_1 常量1 放入栈顶
ladd 当前栈顶两个值相加并把结果放入栈顶
putfield #2 把栈顶结果赋给变量
CAS操作
-
CAS Compare And Swap
JDK提供的非阻塞原子性操作,通过硬件保证了比较-更新操作的原子性 -
JDK里面的Unsafe类提供一系列的compareAndSwap*方法
-
boolean compareAndSwapLong(Object obj,long valueOffset,long expect,long update)
- compareAndSwap比较并交换
- CAS四个操作数,对象内存位置、对象中变量的偏移量、变量预期值、新的值
- 操作含义:如果对象obj中内存偏移量为valueOffset的变量值为expect,则使用新的值update替换旧的值expect,这是处理器提供的一个原子性指令
-
CAS操作问题:
ABA问题
,变量值产生了环形转换- 线程I使用CAS修改初始值为A的变量X,那么线程I首先会获取当前变量X的值(A),然后使用CAS尝试修改X的值为B,如果CAS操作成功了,程序也不一定正确
- 因为有可能在线程I获取变量X的值A后,线程II使用CAS修改变量X的值为B,然后又使用CAS修改变量X的值为A。所以虽然线程I执行CAS时变量X的值为A,但是这个A不是线程I获取时的A了
- ABA问题
- 如果变量值只能朝着一个方向转换,A—>B,B---->C,不构成环形,就不会存在问题。JDK中的AtomicStampedReference类给每个变量的状态值都配上一个时间戳,从而避免ABA问题
2.7 Unsafe类
Unsafe类中重要方法
-
JDK的rt.jar包中的Unsafe类提供了硬件级别的原子性操作,Unsafe类方法都是native,使用JNI方式访问本地C++实现库。
-
几个常见的方法
- long objectFieldOffset(Field field) 返回指定变量在所属类中的内存偏移地址,该地址仅在Unsafe函数中访问指定字段时使用
- int arrayBaseOffset(Class arrayClass)获取数组中第一个元素的地址
- int arrayIndexScale(Class arrayClass) 获取数组中一个元素所战用的字节
- boolean compareAndSwapLong(Object obj,long offset,long expect,long update)比较并更新
- public native long getLongvolatile(Object obj,long offset)获取对象obj中的偏移量为offset的变量对应volatile语义的值
- void putLongvolatile(Object obj,long offset,long value)设置obj对象中offset偏移量的类型为龙的field的值为value,支持volatile语义
long getAndSetLong(Object obj,long offset,long update){
long l;
do{
l=getLongvolatile(obj,offset);
}while(!compareAndSwap(obj,offset,l,update));
return l;
}
getLongvolatile获取当前变量的值,然后使用CAS原子操作设置新值。使用while循环是考虑到,在多个线程同时调用的情况下CAS失败时需要重试
如何使用Unsafe类
-
使用Unsafe.getUnsafe()方法获取Unsafe类的实例时会报错
- 因为getUnsafe()方法内会判断是不是Bootstrap类加载器加载
- Unsafe是rt.jar包提供的,rt.jar包里面的类是使用Bootstrap类加载器加载的,main函数所在类是AppClassLoader类加载器加载,根据委托机制,会委托给AppClassLoader去加载Unsafe类,所以报错异常
-
通过反射获取Unsafe实例方法
Field field=Unsafe.class.getDeclardeField("theUnsafe");
field.setAccessible(true)
Unsafe unsafe=(Unsafe)field.get(null)
package com.chapter2;
import sun.misc.Unsafe;
import java.lang.reflect.Field;
/**
* @CreateTime: 2021-09-21 13:33
* @Description:
*/
public class UnsafeTest {
//获取Unsafe实例
//获取不到抛出异常,因为Unsafe类是rt.jar包提供的,必须用Bootstrap类加载器加载,
// 而main函数所在类是AppClassLoader类加载器加载的
// static final Unsafe unsafe=Unsafe.getUnsafe();
static final Unsafe unsafe;
//记录变量state在类UnsafeTest中的偏移值
static final long stateOffset;
//volatile变量state
private volatile long state=0;
static{
try{
//使用反射获取Unsafe的成员变量theUnsafe
Field field=Unsafe.class.getDeclaredField("theUnsafe");
//设置为可取
field.setAccessible(true);
//获取该变量的值
unsafe=(Unsafe)field.get(null);
//获取volatile变量state在类中的偏移值
stateOffset=unsafe.objectFieldOffset(UnsafeTest.class.getDeclaredField("state"));
}catch(Exception e){
System.out.println(e.getLocalizedMessage());
throw new Error(e);
}
}
public static void main(String[] args) {
//创建实例,并设置state的值为1
UnsafeTest test=new UnsafeTest();
boolean success=unsafe.compareAndSwapLong(test,stateOffset,0,1);
System.out.println(success);
}
}
2.8 Java中的指令重排序
Java内存模型允许编译器和处理器对指令重排序以提供运行性能,并且只会对不存在数据依赖性的指令重排序。单线程下重排序可以保证结果一致性,但是在多线程下无法保证
package com.chapter2;
/**
* @CreateTime: 2021-09-21 14:15
* @Description: 多线程指令重排序问题 ,输出4或者0
* 解决方法:ready变量用volatile关键字修饰可以保证共享变量内存可见性和避免重排序
* 写volatile变量时,可以确保volatile写之前的操作不会被编译器重排序到volatile写之后
* 读取volatile变量时,可以确保volatile读之后的操作不会被编译器重排序到volatile读之前
*/
public class ThreadReOrderedTest {
private static int num=0;
private static boolean ready=false;
public static void main(String[] args) throws InterruptedException {
Thread rt=new Thread(()->{
while(!Thread.currentThread().isInterrupted()){
//(1)
if(ready){
//(2)
System.out.println(num+num);
}
System.out.println("read thread ...");
}
});
Thread wt=new Thread(()->{
//(3)
num=2;
//(4)
ready=true;
System.out.println("write thread set over ...");
});
rt.start();
wt.start();
Thread.sleep(1);
rt.interrupt();
System.out.println("main exit");
}
}
2.10 伪共享
什么是伪共享
- 为了解决CPU与主存之间运行速度差问题,在CPU与主存之间添加一级或多级高速缓冲存储器cache,cache一般被集成到CPU内部
- cache内部是按行存储的,其中每一行称为一个cache行。Cache行是Cache与主存进行数据交换的单位,Cache行大小一般为2的幂次数字节
- CPU访问变量时,先尝试从cache中获取,如果获取不到,则去主存里获取该变量,然后把该变量所在内存区域的一个cache行大小的内存复制到cache里面。可能会把多个变量存放到一个cache行
- 多线程同时修改一个缓存行里面的多个变量时,由于同时只能有一个线程操作缓存行,所以相比将每个变量放到一个缓存行,性能会下降
- 缓存一致性协议下,CPU2中变量x对应缓存行失效。在线程2写入变量x时只能去二级缓存查找,破坏了一级缓存,而一级缓存比二级缓存更快
为何会出现伪共享
- 多个变量被放入一个缓存行中,并且多个线程同时去写入缓存行中的不同变量。
- 因为缓存和内存交换的单位为一个缓存行,当CPU要查找的变量没有在缓存中命中时,根据程序局部性原理,会把该变量所在内存中大小缓存行的内存放入缓存中
- 地址连续的多个变量才有可能放入一个缓存行。(对线性数组有利)
- 单线程下顺序修改一个缓存行中多个变量,会充分利用程序局部性原则,加速程序运行。多线程下并发修改一个缓存行多个变量时就会竞争缓存行,从而降低程序运行性能
如何避免伪共享
- JDK8之前通过字节填充方式避免伪共享
- 类对象的字节码的对象头占8个字节
- JDK8提供了一个sun.misc.Contended注解,解决伪共享
- 默认情况下,
@Contended
注解只用于Java核心类,比如rt包下的类。若用户类路径下要用到这个注解,需要添加JVM参数:-XX:-RestrictContended
。填充宽度默认为128字节,要自定义宽度,可以设置**-XX:ContendedPaddingWidth
**参数
2.11 锁的概述
乐观锁与悲观锁
- 悲观锁指对数据被外界修改持保守状态,认为数据很容易被修改,所以在数据被处理之前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态
- 乐观锁:人为数据在一般情况下不会造成冲突,所以在访问记录前不会加排他锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测
- 乐观锁并不会使用数据库提供的锁机制,一般在表中添加version字段或使用业务状态来实现。乐观锁直到提交时才锁定,所以不会产生任何死锁
公平锁与非公平锁
-
根据线程获取锁的抢占机制区分
-
公平锁:线程获取锁的顺序按照线程请求锁的时间早晚决定,先到先得(队列)
-
非公平锁:在运行时闯入,不一定先来先得,竞争
-
没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销
-
ReentrantLock类提供了公平和非公平锁的实现
- 通过构造函数传入参数true:公平锁;false:非公平锁,不传参数默认是非公平锁
独占锁与共享锁
- 根据锁只能被单个线程持有还是能被多个线程共同持有分类
- 独占锁保证任何时候都只有一个线程能得到锁,ReetrantLock就是以独占方式实现。独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性
- 共享锁可以同时由多个线程持有,如ReadWriteLock读写锁,允许一个资源可以被多线程同时进行读操作。共享锁是乐观锁,放宽了加锁条件,允许多线程同时读
可重入锁
-
当一个线程要获取一个被其他线程持有的独占锁时,该线程会被阻塞,那么当一个线程再次获取它自己已经获取的锁时是否会被阻塞?如果不被阻塞,则称为可重入锁。synchronized监视器锁时可重入锁
-
原理
- 在锁内部维护一个线程标示,用来标示该锁被哪个线程占用,然后关联一个计数器。一开始计数器为0,说明该锁没有被任何线程独占,当一个线程获取该锁,计数器值为1,这时其他线程再来获取该锁会发现锁的所有者不是自己而被阻塞
- 获得锁的线程再次获取锁时发现锁拥有者是自己,计数器+1,当释放锁后计数器-1.当计数器为0时,锁里面的线程标示为null,这时被阻塞的线程会被唤醒来竞争获取该锁
自旋锁
- 当前线程获取锁失败,不马上阻塞自己,在不放弃CPU使用权情况下,多次尝试获取锁(默认10次,使用-XX:PreBlockSpinsh设置该值),如果尝试指定次数仍未获取锁,则当前线程才会被阻塞挂起
- 自旋锁是用CPU时间换取线程阻塞与调度的开销,但是很有可能这些CPU时间白白浪费了