多线程并发编程(Java)
多线程并发编程
定义
并发和并行的区别
并发: 同一个时间内多个任务同时执行。
并行: 单位时间内多个任务同时执行。
为什么需要
CPU进入多核时代,多个线程同时并发执行任务,减少线程上下文切换,提升系统的整体吞吐量
线程安全
共享资源
该资源被多个线程同时持有,或者多个线程可以同时访问该资源。
线程安全
当多个线程同时读写一个资源的时候,并且没有任何同步措施,导致出现脏数据或者其他不可预见的问题。
共享变量内存可见性
Java内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里边的变量复制一份到自己的工作空间/工作内存。 线程读写变量操作的是自己工作内存中的变量。
Java并发基础
synchronized
定义
synchronized块是java中提供的一种原子性内置锁。Java中的每个对香都可以当作同步锁来使用。这些在Java的内部使用中看不到的锁都称为内部锁或者监视器锁。
执行过程
线程的执行代码在进入synchronized 代码块前会自动获取内部锁,这时候该线程在访问该同步代码块时就会被阻塞挂起。
拿到内部锁的线程会在同步代码块执行结束或者抛出异常后或者在同步代码块调用了该资源的wait系列方法时释放该内置锁。
小结
内置锁是排它锁,也就是当一个线程获取到该锁的时候,其他线程必须等待该线程释放锁,才能获取该锁。
Java中的线程和操作系统中的线程是一一对应的。阻塞线程,需要线程从用户态切换到内核态才能够进行阻塞。
synchronized 内存语义
在synchronized中使用到的变量从线程的工作内存中清除,直接从主内存中读取。
退出synchronized的语义
把synchronized作用块内修改的变量刷新到主内存中去。
synchronized除了可以解决共享变量内存可见性问题,也可以用来做原子性操作。
volatile
volatile关键字是为了解决共享内存可见性问题。该关键字保证一个线程对该变量的修改其他线程立马可见。
原理
在线程写入变量时,不会把值缓存在寄存器或者其他缓存地方,而是直接写入主内存,其他线程读该变量时,不会再共享内存中读取,直接读取主内存。
使用时机
- 写入值不依赖变量的当前值
- 读写变量值时没哟加锁。
unsafe 类
JDK中的rt.jar 的unfafe类提供了硬件级别的原子性操作。UnSafe类中的所有方法都是native的,他们通过JNI直接访问本地C++实现库。
指令集重排
java内存模型允许编译器和处理器通过指令集重排以提成系统性能,并且只对不存在的数据依赖性的指令重排序。
单线程下指令集重排的执行结果和程序本身顺序执行的结果一致,但在多线程下就会存在问题。
原子性操作
定义
是指一系列操作要么全部执行成功,要么全都不执行。
CAS操作
锁的缺点
当一个线程没有获取到锁的时候会被阻塞挂起,这会导致线程上下文切换和重新调度的开销。
volatile解决共享内存不可见的问题,但是并不能保证读-改-写等操作的原子性问题。
CAS
Compare And Set。是JDK提供的非阻塞原子性操作,它通过硬件保证了更新操作的原子性。
ABA 问题
描述:
当一个线程1将变量值A通过CAS操作,更新为B,结果一定是正确的吗?
答案: 不是
假设线程1要将变量X从A更新到B,但是在更新之前,另一个线程2将X的值从A改为B,有立即将X从B更改到A,这个时候虽然线程1更新成功了,但是变量X的值A已经不是当时的那个A了。
产生原因:
更新的变量值产生了环形转换。如果变量的值只能朝着一个方向转换,就不会存在ABA问题。
JDK中的AtomicStampedReference类为每个变量的状态值都分配了一个时间戳,从而避免了ABA的问题。
伪共享
定义
为了解决CPU和主内存的速度差问题,会在CPU的主内存之间引入一级或者多级高速缓存处理器(Cache)。这个Cache一般是集成在CPU内部的。
在Cache内部是按行存储的,其中每一行称为一个cache行,cache行是Cache与主内存交换的单位。
出现原因
当CPU访问某个变量的时候会首先查看CPU cache 行中是否存在该变量,如果存在就直接获取,如果不存在就去主内存中取,之后复制一行到Cache中,此处放在Cache行中的是内存块,而不是单个变量。
所以可能出现一个cache行中存在多个变量。当多个线程同时修改一个cache行中的多个变量时,由于一次只能有一个线程操作缓存行,所以对比将每个变量放在单独的cache行,性能会有所下降, 这就是伪共享。
如何避免
- Java 8 之前
通过填充字节的方式去解决。空间换时间。
具体操作: 让一个变量占一个缓存行,如果变量不够长,就通过填充字节的方式达到。 - Java 8 中引入了@sun.misc.Contended 注解
注意
@Contended 注解只能在java的核心类中使用,用户路径下的类如果想用,需要设置JVM参数-XX:-RestrictContended, 填充长度默认是128字节。
要指定填充宽度,设置-XX:ContendedPaddingWidth
锁🔒
乐观锁
乐观锁和悲观锁是数据库中的概念,但在并发包中也引入了类似的思想。
定义
认为数据在一般情况下不会引起冲突,所在访问记录前不会加排它锁,而是在进行数据更新的时候才会正式的校验属否冲突
悲观锁
定义
指数据被外界修改持保守态度。认为数据很容易被其他线程修改,所以在处理数据之前要对数据进行开锁,并且在整个操作过程中,使数据处于锁定状态。
悲观锁的实现主要依赖数据库的锁机制,即在数据库中,对修改的数据首先加排它锁,如果获取锁失败,就等待或者抛出异常;如果获取成功,对记录进行操作,然后提交事务释放排它锁。
公平锁与非公平锁
根据线程获取锁的的抢占机制,分为公平锁和非公平锁。公平锁是根据线程到达的顺序进行排队,最早的线程,最先获取到锁。非公平锁是在运行是闯入,也就是先到不一定先得。
ReentrantLock提供了公平锁和非公平锁的两种实现。默认是非公平锁。
建议
在没有公平性需求的情况下优先使用非公平锁,因为公平锁会带来性能开销。
独占锁与共享锁
根据锁只能被单个线程还是多个线程共同持有,锁分为独占锁和共享锁。
独占锁:
独占锁保证任何时候只能有一个线程获取到锁。 ReentrantLock就是独占锁实现的。
共享锁则可以同时被多个线程同时持有。ReadWriteLock读写锁 允许一个资源同时可以被多个线程读。
独占锁是一种悲观锁。由于每次只能有一个线程访问,这限制了并发性,但是读操作并不会影响数据的一致性,而独占锁只允许一个资源在同一时间被一个线程访问,其他线程只能等待。
共享锁是一种乐观素。它放宽了加锁的条件,可以多线程同时对一个资源进行读。
可重入锁
一个线程获取被另一个线程持有的锁时会被阻塞。那么当一个线程再次获取他自己持有的锁时会不会阻塞呢? 不会阻塞的是可重入锁。
只要当该线程获取到该资源锁时,就可以不限次数地访问该锁锁住的代码。严格意义上说是有限的。
自旋锁
由于Java中的线程和操作系统中的线程是一一对应的,当线程获取不到锁时会从用户态转化为内核态,并且挂起。当该线程获取到锁时,就要从内核态转化为用户态而唤醒该线程。
而从用户态转化为内核态开销比较大,在一定程度上会影响并发性能。
自旋锁则是: 如果当前线程在获取锁的时候发现,已经被其他线程持有了,他不会马上阻塞自己,而是在不释放CPU资源的情况下多次尝试获取,如果在指定次数(默认10次,可以通过设置JVM参数-XX:PreBlockSpinsh
)还没有获取到资源才会挂起。