Java-并发编程-进阶篇
在上一篇幅中对并发编程进行了简单介绍:并发与并行,进程与线程,以及并发编程的简单代码
但是在企业中往往并不能解决实际问题,例如:
1.synchronized关键字在企业开发中会大大降低系统的性能,有什么解决方式,或者其他的替代方案
2.当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。线程对象在不同的时期有不同的状态,如果不清楚认识,则无法清楚自己的代码出现的问题
3.随处可见的 new Thread(); 缺少对于线程、以及资源的管理,有什么解决方案?
查看资料:
https://javaguide.cn/java/concurrent/jmm.html#jmm-是如何抽象线程和主内存之间的关系
https://www.cnblogs.com/zhangxiann/p/13490598.html
https://www.iteye.com/topic/652440
https://www.cnblogs.com/dolphin0520/p/3920373.html
一、线程
1.线程状态
当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。线程对象在不同的时期有不同的状态。那么Java中的线程存在哪几种状态呢?Java中的线程状态被定义在了java.lang.Thread.State枚举类中,State枚举类的源码如下:
点击查看代码
public enum State {
/**
* Thread state for a thread which has not yet started.
*/
/* 新建 */
NEW,
/**
* Thread state for a runnable thread. A thread in the runnable
* state is executing in the Java virtual machine but it may
* be waiting for other resources from the operating system
* such as processor.
*/
/* 可运行状态 */
RUNNABLE,
/**
* Thread state for a thread blocked waiting for a monitor lock.
* A thread in the blocked state is waiting for a monitor lock
* to enter a synchronized block/method or
* reenter a synchronized block/method after calling
* {@link Object#wait() Object.wait}.
*/
/* 阻塞状态 */
BLOCKED,
/**
* Thread state for a waiting thread.
* A thread is in the waiting state due to calling one of the
* following methods:
* <ul>
* <li>{@link Object#wait() Object.wait} with no timeout</li>
* <li>{@link #join() Thread.join} with no timeout</li>
* <li>{@link LockSupport#park() LockSupport.park}</li>
* </ul>
*
* <p>A thread in the waiting state is waiting for another thread to
* perform a particular action.
*
* For example, a thread that has called <tt>Object.wait()</tt>
* on an object is waiting for another thread to call
* <tt>Object.notify()</tt> or <tt>Object.notifyAll()</tt> on
* that object. A thread that has called <tt>Thread.join()</tt>
* is waiting for a specified thread to terminate.
*/
/* 无限等待状态 */
WAITING,
/**
* Thread state for a waiting thread with a specified waiting time.
* A thread is in the timed waiting state due to calling one of
* the following methods with a specified positive waiting time:
* <ul>
* <li>{@link #sleep Thread.sleep}</li>
* <li>{@link Object#wait(long) Object.wait} with timeout</li>
* <li>{@link #join(long) Thread.join} with timeout</li>
* <li>{@link LockSupport#parkNanos LockSupport.parkNanos}</li>
* <li>{@link LockSupport#parkUntil LockSupport.parkUntil}</li>
* </ul>
*/
/* 计时等待 */
TIMED_WAITING,
/**
* Thread state for a terminated thread.
* The thread has completed execution.
*/
/* 终止 */
TERMINATED;
}
线程状态 | 具体含义 |
---|---|
NEW | 一个尚未启动的线程的状态。也称之为初始状态、开始状态。线程刚被创建,但是并未启动。还没调用start方法。MyThread t = new MyThread()只有线程象,没有线程特征。 |
RUNNABLE | 当我们调用线程对象的start方法,那么此时线程对象进入了RUNNABLE状态。那么此时才是真正的在JVM进程中创建了一个线程,线程一经启动并不是立即得到执行,线程的运行与否要听令与CPU的调度,那么我们把这个中间状态称之为可执行状态(RUNNABLE)也就是说它具备执行的资格,但是并没有真正的执行起来而是在等待CPU的调度。 |
BLOCKED | 当一个线程试图获取一个对象锁,而该对象锁被其他的线程持有,则该线程进入Blocked状态;当该线程持有锁时,该线程将变成Runnable状态。 |
WAITING | 一个正在等待的线程的状态。也称之为等待状态。造成线程等待的原因有两种,分别是调用Object.wait()、join()方法。处于等待状态的线程,正在等待其他线程去执行一个特定的操作。例如:因为wait()而等待的线程正在等待另一个线程去调用notify()或notifyAll();一个因为join()而等待的线程正在等待另一个线程结束。 |
TIMED_WAITING | 一个在限定时间内等待的线程的状态。也称之为限时等待状态。造成线程限时等待状态的原因有三种,分别是:Thread.sleep(long),Object.wait(long)、join(long)。 |
TERMINATED | 一个完全运行完成的线程的状态。也称之为终止状态、结束状态 |
2.线程状态转换
3.案例演示
案例一:
本案例主要演示TIME_WAITING的状态转换。
点击查看代码
package com.vayne.thread;
/**
* @author vayne
* @date 2023-10-31
*/
public class ThreadStatesDemo {
public static void main(String[] args) throws InterruptedException {
//定义一个内部线程
Thread thread = new Thread(() -> {
System.out.println("2.执行thread.start()之后,线程的状态:" + Thread.currentThread().getState());
try {
//休眠100毫秒
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("4.执行Thread.sleep(long)完成之后,线程的状态:" + Thread.currentThread().getState());
});
//获取start()之前的状态
System.out.println("1.通过new初始化一个线程,但是还没有start()之前,线程的状态:" + thread.getState());
//启动线程
thread.start();
//休眠50毫秒
Thread.sleep(50);
//因为thread1需要休眠100毫秒,所以在第50毫秒,thread处于sleep状态
//用main线程来获取thread1线程的状态,因为thread1线程睡眠时间较长
//所以当main线程执行的时候,thread1线程还没有睡醒,还处于计时等待状态
System.out.println("3.执行Thread.sleep(long)时,线程的状态:" + thread.getState());
//main线程主动休眠150毫秒,第150毫秒时,thread早已执行完毕
Thread.sleep(100);
System.out.println("5.线程执行完毕之后,线程的状态:" + thread.getState() + "\n");
}
}
案例二:
本案例主要演示WAITING的状态转换。
点击查看代码
package com.vayne.thread;
/**
* @author vayne
* @date 2023-10-31
*/
public class ThreadStatusWaitingDemo {
public static void main(String[] args) throws InterruptedException {
//定义一个对象,用来加锁和解锁
Object obj = new Object();
//定义一个内部线程
Thread thread1 = new Thread(() -> {
System.out.println("2.执行thread.start()之后,线程的状态:" + Thread.currentThread().getState());
synchronized (obj) {
try {
//thread1需要休眠100毫秒
Thread.sleep(100);
//thread1 100毫秒之后,通过wait()方法释放obj对象是锁
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("4.被object.notify()方法唤醒之后,线程的状态:" + Thread.currentThread().getState());
});
//获取start()之前的状态
System.out.println("1.通过new初始化一个线程,但是还没有start()之前,线程的状态:" + thread1.getState());
//启动线程
thread1.start();
//main线程休眠150毫秒
Thread.sleep(150);
//因为thread1在第100毫秒进入wait等待状态,所以第150秒肯定可以获取其状态
System.out.println("3.执行object.wait()时,线程的状态:" + thread1.getState());
//声明另一个线程进行解锁
new Thread(() -> {
synchronized (obj) {
//唤醒等待的线程
obj.notify();
}
}).start();
//main线程休眠10毫秒等待thread1线程能够苏醒
Thread.sleep(10);
//获取thread1运行结束之后的状态
System.out.println("5.线程执行完毕之后,线程的状态:" + thread1.getState() );
}
}
案例三:
本案例主要演示BLOCKED的状态转换。
点击查看代码
package com.vayne.thread;
import java.security.PublicKey;
/**
* @author vayne
* @date 2023-11-01
*/
public class ThreadStatusBlockedDemo {
//定义一个对象,用来加锁和解锁
public static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
////定义一个线程,先抢占了obj对象的锁
Thread thread2 = new Thread(() -> {
synchronized (obj) {
try {
Thread.sleep(1000);////第一个线程要持有锁1000毫秒
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
thread2.start();
//定义目标线程,获取等待获取obj的锁
Thread thread1 = new Thread(() -> {
System.out.println("2.执行thread.start()之后,线程的状态:" + Thread.currentThread().getState());
try {
Thread.sleep(100);//100毫秒后,thread1开始抢锁,但由于锁被thread2持有,抢不到,处于阻塞状态
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (obj) {
System.out.println("4.抢锁成功,线程的状态:" + Thread.currentThread().getState());
}
});
System.out.println("1.通过new初始化一个线程,但是还没有start()之前,线程的状态:" + thread1.getState());
thread1.start();
Thread.sleep(200);
System.out.println("3.抢锁失败后,线程的状态:" + thread1.getState());//此时线程正处于阻塞
Thread.sleep(1500);//1700毫秒后,线程一定执行完毕
System.out.println("5.线程执行完毕后,线程的状态:" + thread1.getState());
}
}
二、线程池
提到池,大家应该能想到的就是水池。水池就是一个容器,在该容器中存储了很多的水。那么什么是线程池呢?线程池也是可以看做成一个池子,在该池子中存储很多个线程。
线程池存在的意义:
系统创建一个线程的成本是比较高的,因为它涉及到与操作系统交互,当程序中需要创建大量生存期很短暂的线程时,频繁的创建和销毁线程对系统的资源消耗有可能大于业务处理是对系统资源的消耗,这样就有点"舍本逐末"了。针对这一种情况,为了提高性能,我们就可以采用线程池。线程池在启动的时,会创建大量空闲线程,当我们向线程池提交任务的时,线程池就会启动一个线程来执行该任务。等待任务执行完毕以后,线程并不会死亡,而是再次返回到线程池中称为空闲状态。等待下一次任务的执行。
1.自定义线程池的设计思路
与Java-并发编程-基础篇 中的生产者消费者案例模型相像
- 准备一个任务容器
- 一次性启动多个(2个)消费者线程
- 刚开始任务容器是空的,所以线程都在wait
- 直到一个外部线程向这个任务容器中扔了一个"任务",就会有一个消费者线程被唤醒
- 这个消费者线程取出"任务",并且执行这个任务,执行完毕后,继续等待下一次任务的到来
在整个过程中,都不需要创建新的线程,而是循环使用这些已经存在的线程
案例代码:
点击查看代码
package com.vayne.thread;
import java.util.concurrent.BlockingQueue;
/**
* @author vayne
* @date 2023-11-01
*/
public class ThreadPoolDemo {
/*线程池初始化线程的个数*/
private int poolSize;
/*任务容器*/
private BlockingQueue<Runnable> queue;
private ThreadPoolDemo() {
}
public ThreadPoolDemo(int poolSize, BlockingQueue<Runnable> queue, String ThreadName) {
this.poolSize = poolSize;
this.queue = queue;
for (int i = 0; i < poolSize; i++) {
new TaskThread(ThreadName + i).start();
}
}
public boolean submit(Runnable task) throws InterruptedException {
queue.put(task);
return true;
}
private class TaskThread extends Thread {
public TaskThread(String names) {
super(names);
}
@Override
public void run() {
while (true) {
try {
Runnable runnable = queue.take();
runnable.run();
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
}
package com.vayne.thread;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
/**
* @author vayne
* @date 2023-11-01
*/
public class ThreadPoolTest {
public static void main(String[] args) throws InterruptedException {
ThreadPoolDemo pool = new ThreadPoolDemo(2,
new ArrayBlockingQueue<>(5),
"线程池中的线程");
for (int i = 0; i < 10; i++) {
int finalI = i;
pool.submit(() -> {
System.out.println("当前线程是:" + Thread.currentThread().getName()+", 当前正在执行第" + finalI + "个任务");
System.out.println();
});
}
}
}
2.JDK中的线程池
Executors
JDK对线程池也进行了相关的实现,在真实企业开发中我们也很少去自定义线程池,而是使用JDK中自带的线程池。
我们可以使用Executors中所提供的静态方法来创建线程池。
获取线程池的方法:
通过不同的方法创建出来的多种线程池具有不同的特点:
Executors.newCachedThreadPool();
-->创建一个可缓存线程池,可灵活的去创建线程,并且灵活的回收线程,若无可回收,则新建线程。
Executors.newFixedThreadPool(int nThreads);
--> 初始化一个具有固定数量线程的线程池
Executors.newSingleThreadExecutor();
-->初始化一个具有一个线程的线程池,做完一个,再做一个,不停歇,直到做完,老黄牛性格
Executors.newSingleThreadScheduledExecutor();
-->初始化一个具有一个线程的线程池,支持定时及周期性任务执行,按照固定的计划去执行线程,一个做完之后按照计划再做另一个
这个方法返回的都是ExecutorService类型的对象(ScheduledExecutorService继承ExecutorService),而ExecutorService可以看做就是一个线程池,那么ExecutorService给我们提供了哪些方法供我们使用呢?
ExecutorService中的常见方法
Future<?> submit(Runnable task);
-->提交任务方法
void shutdown();
-->关闭线程池的方法
点击查看SingleThreadScheduledExecutor()代码
package com.vayne.thread;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* @author vayne
* @date 2023-11-01
*/
public class ThreadPoolExecutor {
public static void main(String[] args) throws InterruptedException {
/**
* 定时执行:executor.schedule
* command: 任务类对象
* delay : 延迟多长时间开始执行任务, 任务提交到线程池以后我们需要等待多长时间开始执行这个任务
* unit : 指定时间操作单元
*/
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
System.out.println("定时任务预启动时间" + new Date(System.currentTimeMillis()));
executor.schedule(() -> {
System.out.println("定时任务执行时间:" + new Date(System.currentTimeMillis()));
}, 3, TimeUnit.SECONDS);
/**
* 周期性执行:executor.scheduleAtFixedRate
* command: 任务类对象
* initialDelay: 延迟多长时间开始第一次该执行任务, 任务提交到线程池以后我们需要等待多长时间开始第一次执行这个任务
* period: 下一次执行该任务所对应的时间间隔
* unit: 指定时间操作单元
*/
System.out.println("周期任务预启动时间" + new Date(System.currentTimeMillis()));
executor.scheduleAtFixedRate(()->{
System.out.println("周期任务的当前执行时间:" + new Date(System.currentTimeMillis()) );
},5,3,TimeUnit.SECONDS);
Thread.sleep(20000);
executor.shutdown();
}
}
ThreadPoolExecutor
刚才我们是通过Executors中的静态方法去创建线程池的,通过查看源代码我们发现,其底层都是通过ThreadPoolExecutor构建的。比如:
点击查看代码
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
那么也可以使用ThreadPoolExecutor去创建线程池。
以下是完整的线程池构造方法:
点击查看线程池构造方法
public ThreadPoolExecutor(
//corePoolSize:核心线程的最大值,不能小于0
int corePoolSize,
//maximumPoolSize:最大线程数,不能小于等于0,maximumPoolSize >= corePoolSize
int maximumPoolSize,
//keepAliveTime:空闲线程最大存活时间,不能小于0
long keepAliveTime,
//unit:时间单位
TimeUnit unit,
//workQueue:任务队列,不能为null
BlockingQueue<Runnable> workQueue,
//threadFactory:创建线程工厂,不能为null
ThreadFactory threadFactory,
//handler:任务的拒绝策略,不能为null
RejectedExecutionHandler handler
}
参数详解:
参数一:corePoolSize:
核心线程数:是指线程池中长期存活的线程数。
这就好比古代大户人家,会长期雇佣一些“长工”来给他们干活,这些人一般比较稳定,无论这一年的活多活少,这些人都不会被辞退,都是长期生活在大户人家的。
参数二:maximumPoolSize
最大线程数:线程池允许创建的最大线程数量,当线程池的任务队列满了之后,可以创建的最大线程数。
这是古代大户人家最多可以雇佣的人数,比如某个节日或大户人家有人过寿时,因为活太多,仅靠“长工”是完不成任务,这时就会再招聘一些“短工”一起来干活,这个最大线程数就是“长工”+“短工”的总人数,也就是招聘的人数不能超过 maximumPoolSize。
注意:最大线程数 maximumPoolSize 的值不能小于核心线程数 corePoolSize,否则在程序运行时会报 IllegalArgumentException 非法参数异常
参数三:keepAliveTime
空闲线程存活时间,当线程池中没有任务时,会销毁一些线程,销毁的线程数=maximumPoolSize(最大线程数)-corePoolSize(核心线程数)。
还是以大户人家为例,当大户人家比较忙的时候就会雇佣一些“短工”来干活,但等干完活之后,不忙了,就会将这些“短工”辞退掉,而 keepAliveTime 就是用来描述没活之后,短工可以在大户人家待的(最长)时间。
参数四:TimeUnit
时间单位:空闲线程存活时间的描述单位,此参数是配合参数 3 使用的。
参数五BlockingQueue
阻塞队列:线程池存放任务的队列,用来存储线程池的所有待执行任务。具体的阻塞队列可以参考Java-并发编程-基础篇 比较常用的是 LinkedBlockingQueue,线程池的排队策略和 BlockingQueue 息息相关。
参数六:ThreadFactory
线程工厂:线程池创建线程时调用的工厂方法,通过此方法可以设置线程的优先级、线程命名规则以及线程类型(用户线程还是守护线程)等。
点击查看默认的线程工厂源代码
static class DefaultThreadFactory implements ThreadFactory {
private static final AtomicInteger poolNumber = new AtomicInteger(1);
private final ThreadGroup group;
private final AtomicInteger threadNumber = new AtomicInteger(1);
private final String namePrefix;
DefaultThreadFactory() {
SecurityManager s = System.getSecurityManager();
group = (s != null) ? s.getThreadGroup() :
Thread.currentThread().getThreadGroup();
namePrefix = "pool-" +
poolNumber.getAndIncrement() +
"-thread-";
}
public Thread newThread(Runnable r) {
Thread t = new Thread(group, r,
namePrefix + threadNumber.getAndIncrement(),
0);
if (t.isDaemon())
t.setDaemon(false);
if (t.getPriority() != Thread.NORM_PRIORITY)
t.setPriority(Thread.NORM_PRIORITY);
return t;
}
}
参数7:RejectedExecutionHandler
拒绝策略:当线程池的任务超出线程池队列可以存储的最大值之后,执行的策略。
默认的拒绝策略有以下 4 种:
AbortPolicy:拒绝并抛出异常。
CallerRunsPolicy:使用当前调用的线程来执行此任务。
DiscardOldestPolicy:抛弃队列头部(最旧)的一个任务,并执行当前任务。
DiscardPolicy:忽略并抛弃当前任务。
线程池的默认策略是 AbortPolicy 拒绝并抛出异常。
线程池的执行过程
请看下方图示:
当我们通过submit方法向线程池中提交任务的时候,具体的工作流程如下:
- 客户端每次提交一个任务,线程池就会在核心线程池中创建一个工作线程来执行这个任务。当核心线程池中的线程已满时,则进入下一步操作。
- 把任务试图存储到工作队列中。如果工作队列没有满,则将新提交的任务存储在这个工作队列里,等待核心线程池中的空闲线程执行。如果工作队列满了,则进入下个流程。
- 线程池会再次在非核心线程池区域去创建新工作线程来执行任务,直到当前线程池总线程数量超过最大线程数时,就是按照指定的任务处理策略处理多余的任务。
为什么线程池在执行过程中,当核心线程数corePoolSize达到最大,不直接新建临时线程呢?
这是我们每向池中丢一个任务,执行对应execute的源码
我们看到核心方法其实是addWorker,而它如何保证了原子性呢?
看到底层利用原子类加自旋锁保证了原子性,其实对于线程池类ThreadPoolExecutor本身也有private final ReentrantLock mainLock = new ReentrantLock();
可以看到在核心线程数满的情况下,先比于新增线程,在资源耗费上远远不如直接放在工作队列中来的简单。
三、浅入并发编程
三个核心概念
在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。我们先看具体看一下这三个概念:
1.原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
举个最简单的例子,大家想一下假如为一个32位的变量赋值过程不具备原子性的话,会发生什么后果?
i = 9;
假若一个线程执行到这个语句时,暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。
那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。
2.可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
举个简单的例子,看下面这段代码:
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
假若执行线程1、线程2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到本地内存,然后赋值为10,那么在线程1的本地内存当中i的值变为10了,却没有立即写入到主存当中。
此时线程2执行 j = i,它会先去主存读取i的值并加载到线程2的本地内存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.
这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
3.有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。
举个简单的例子,看下面这段代码:
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的,它只会对不存在数据依赖性的指令进行重排序。
比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。
虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?
下面看一个例子:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。
从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
JMM
概述:JMM(Java Memory Model)Java内存模型,是java虚拟机规范中所定义的一种内存模型。
为什么要遵守这些并发相关的原则和规范呢? 这是因为并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。就比如说我们上面提到的指令重排序就可能会让多线程程序的执行出现问题,为此,JMM 抽象了 happens-before 原则(后文会详细介绍到)来解决这个指令重排序问题。
Java内存模型(Java Memory Model)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。
特点:
-
所有的共享变量都存储于主内存(计算机的RAM)这里所说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
-
每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
-
线程对变量的所有的操作(读,写)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存完成。
线程 1 与线程 2 之间如果要进行通信的话,必须要经历下面 2 个步骤:
- 线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中去。
- 线程 2 到主存中读取对应的共享变量的值。
也就是说,JMM 为共享变量提供了可见性的保障。
关于主内存与工作内存直接的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种同步操作(了解):
-
锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。
-
解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
-
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
-
load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
-
use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
-
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
-
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
-
write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。
除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行(了解):
-
不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
-
一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
-
一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
-
如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
-
如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。
了解JMM内存模型后,我们回过头来看并发编程的三个概念:
1.原子性
观察下面代码:
x = 10; //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句4
可能会说上面的4个语句中的操作都是原子性操作。其实只有语句1是原子性操作,其他三个语句都不是原子性操作。
-
语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
-
语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
-
同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。
所以上面4个语句只有语句1的操作具备原子性。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
2.可见性
对于可见性,Java提供了volatile关键字来保证可见性。
1. 当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
2. 而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
3. 另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
3.有序性:
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在volatile关键字会详细解释)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
浅聊hanppens-before原则
-
程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
-
锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
-
volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
-
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
-
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
-
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
-
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
-
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
Volatile关键字
其实浅入并发编程到目前的知识都是在对Volatile关键字做铺垫
下面我们直接开始看看他的详解:
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
-
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的(保证可见性)。
-
禁止进行指令重排序(保证有序性)。
先看一段代码,假如线程1先执行,线程2后执行:
//线程1
boolean stop = false;
while(!stop){
doSomething();
}
//线程2
stop = true;
很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。
下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。
那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
但是用volatile修饰之后就变得不一样了:
-
使用volatile关键字会强制将修改的值立即写入主存;
-
使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
-
由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。
那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的工作内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。
那么线程1读取到的就是最新的正确的值。
volatile是否保证原子性?
点击查看代码
package com.vayne.thread;
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) throws InterruptedException {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
//这里规定线程数大于2,一般有GC线程以及main主线程
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(test.inc);
}
}
上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。
但是结果总是会小于10000,这是为什么呢?
这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。
在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:
假如某个时刻变量inc的值为100:
线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;
然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,所以线程2会直接去主存读取inc的值,发现inc的值时100,然后进行加1操作,并把101写入工作内存,最后写入主存,此时volatile发出同步主存信号,但是线程1还没有接到的时候,线程1同时进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为100,所以线程1对inc进行加1操作后inc的值为101,然后将101写入工作内存,最后写入主存。
那么两个线程分别进行了一次自增操作后,inc只增加了1。
解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before规则中的volatile变量规则,但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。
更加底层一点的来说:
虽然volatile保证了多线程间共享变量操作可见性,但是没有办法保证原子性。 i++是非原子性。这样就可能被线程调度所影响。
i=i+1;
线程1读取 i 的值之后,有可能被线程调度器剥夺了执行权,进入了就续状态,让线程2进入运行状态。
线程2读取 i 的值之后,并使其+1,更新回主内存。
此时线程1的 i 的值确实已经过期了,但是线程1不会再次读取 i ,而是使用上次读取的值+1交给 i。
一个线程是一个顺序执行流,当这个顺序执行流执行过半,被线程调度器剥夺执行权后,等再获得执行权,其上方的代码是不会再执行一遍的。
这是不会再读取 i 的根本原因。
根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。
把上面的代码改成以下任何一种都可以达到效果:
方案一:synchronized同步代码块
点击查看代码
package com.vayne.thread;
public class Test {
public int inc = 0;
public synchronized void increase() {
inc++;
}
public static void main(String[] args) throws InterruptedException {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(test.inc);
}
}
方案二:lock锁
点击查看代码
package com.vayne.thread;
import java.util.concurrent.locks.ReentrantLock;
public class Test {
public int inc = 0;
public ReentrantLock lock = new ReentrantLock();
public void increase() {
lock.lock();
try {
inc++;
}finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(test.inc);
}
}
方案三:使用Atomic原子类
点击查看代码
package com.vayne.thread;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
public class Test {
// public int inc = 0;
public AtomicInteger inc = new AtomicInteger();
public void increase() {
inc.incrementAndGet();
}
public static void main(String[] args) throws InterruptedException {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(test.inc);
}
}
在java 1.5的java.util.concurrent.atomic
包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。
对于原子类下文会详细解释其原理
volatile能保证有序性吗?
在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。
volatile关键字禁止指令重排序有两层意思:
-
当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
-
在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
//x、y为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。
volatile的原理和实现机制
前面讲述了源于volatile关键字的一些使用,下面我们来探讨一下volatile到底如何保证可见性和禁止指令重排序的。
下面这段话摘自《深入理解Java虚拟机》:
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
-
它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
-
它会强制将对缓存的修改操作立即写入主存;
-
如果是写操作,它会导致其他CPU中对应的缓存行无效
(使得本 CPU的 Cache 写人了内存该写人动作也会引起别的 CPU 或者别的内核无效化(Invalidate)其 Cache)。
使用volatile的场景
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
-
对变量的写操作不依赖于当前值
-
该变量没有包含在具有其他变量的不变式中
对变量的写操作不依赖于当前值如何理解?
就是如果变量a定义为volatile变量,做诸如
a++;
a = a + 2;
a = 6 * a;
a = a * a;
这些操作,在多线程场景下会出现共享变量不一致的情形。
原因就是volatile无法保证原子性。如果指令执行到中间被打断,就会出现共享变量不一致的情形。
该变量没有包含在具有其他变量的不变式中如何理解?
点击查看代码
public class NumberRange {
private int lower, upper;
public int getLower() {
return lower;
}
public int getUpper() {
return upper;
}
public void setLower(int value) {
if (value > upper)
throw new IllegalArgumentException(...);
lower = value;
}
public void setUpper(int value) {
if (value < lower)
throw new IllegalArgumentException(...);
upper = value;
}
}
代码显示了一个非线程安全的数值范围类。它包含了一个不变式 —— 下界总是小于或等于上界。
这种方式限制了范围的状态变量,因此将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全;从而仍然需要使用同步。否则,如果凑巧两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。例如,如果初始状态是 (0, 5),同一时间内,线程 A 调用 setLower(4) 并且线程 B 调用 setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是 (4, 3) —— 一个无效值。至于针对范围的其他操作,我们需要使 setLower() 和 setUpper() 操作原子化 —— 而将字段定义为 volatile 类型是无法实现这一目的的。
使用场景一:状态标记量
volatile boolean flag = false;
while(!flag){
doSomething();
}
public void setFlag() {
flag = true;
}
使用场景二:double check
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
为什么会有doublecheck?
我们会以一个经典案例:单例模式,来探讨一下
我们不使用double check时候的单例模式,真的访问的实例就是一致的吗?
点击原始代码
package com.vayne.threadVolatile;
/**
* @author vayne
* @date 2023-11-02
*/
public class Singleton {
private static Singleton instance;
private Singleton (){}
//获取实例
public static Singleton getInstance(){
if(instance == null){
instance = new Singleton();
}
return instance;
}
}
package com.vayne.threadVolatile;
/**
* @author vayne
* @date 2023-11-02
*/
-------------------------------------------------------------------------------------------
class TestSingleton implements Runnable {
private static Singleton singleton;
@Override
public void run() {
int count = 0;
while (count < 10000) {
count++;
}
singleton = Singleton.getInstance();
}
public static Singleton getSingle() {
while (true) {
if (singleton != null) break;
}
return singleton;
}
}
public class Test {
public static void main(String[] args) {
TestSingleton one = new TestSingleton();
TestSingleton two = new TestSingleton();
TestSingleton three = new TestSingleton();
new Thread(one).start();
new Thread(two).start();
new Thread(three).start();
System.out.println("one和two的比较:"+(one.getSingle() == two.getSingle()));
System.out.println("one和three的比较:"+(one.getSingle() == three.getSingle()));
System.out.println("two和three的比较:"+(two.getSingle() == three.getSingle()));
}
}
查看控制台输出结果:发现
说明这个单例模式存在问题,那么问题的根源在于:
我们知道Java的多线程是开启线程资源,每一个线程是通过抢占CPU时间片执行的:
第一种情况:暂不考虑发生指令重排,此时没有任何锁的限制,也就是:
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
当线程一通过判断进入到获取实例的代码中的时候还没执行赋值的时候,这时候线程二正好拿到时间片执行发现这个实例为空,于是它也进入到了创建实例的代码块中,所以创建了两个对象,从而导致创建了两个不同的对象。
那么解决的方案就是要加上synchronized关键字
但是我们加上synchronized之后会大大降低系统的性能
所以我们会在锁外加上一个if判断
此时就成了这样:
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
//第一次检测
if (instance == null) {
//同步
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
但是,这时就有会有隐藏问题:执行时发生了指令重排
一个对象的初始化不是原子性的操作,可以分为 3 步:
1.分配内存空间
2.初始化对象
3.设置 instance 指向分配对象的内存地址
但是我们也知道了为了提升处理速度带来的指令重排,那么此时执行的结果可能就是:
1.分配内存空间
2.设置 instance 指向分配对象的内存地址,但是对象还没初始化,但此时 instance != null
3.初始化对象
我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常或jvm的其他异常。
解决方法是使用volatile修饰instance变量,禁止指令重排序即可。
所以出现了终极方案:
public class Singleton {
private volatile static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
//第一次检测
if (instance == null) {
//同步
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
如果没有使用double checked locking,则在多线程环境下可能会出现重复创建对象的问题。这是因为两个线程同时进入if语句块时,无法判断另一个线程是否已经创建了对象,因此可能会导致重复创建对象。所以我们先在判断前加入了synchronized关键字,但是同步锁会大大降低系统性能,因此我们在锁的外面加上了if判断,但是此时可能会发生指令重排,导致变量被赋予了内存地址,但是没有实际对象,所以volatile解决了这个问题。
double-check中volatile关键字的必要性
而在double checked locking中,如果没有使用volatile关键字,则无法保证多线程环境下的线程安全。这是因为如果没有使用volatile关键字,两个线程无法同步访问和修改对象的状态,可能会导致一个线程看到的对象状态不是最新的,从而导致线程安全问题。因此,在使用double checked locking时,建议使用volatile关键字来修饰对象的状态,以确保多线程环境下的线程安全。
volatile和synchronized的区别
-
volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized 这是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住,直到该线程完成变量操作为止。
-
volatile 仅能用在变量级别;synchronized 则可以用在变量、方法和类级别
-
volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量修改的可见性和原子性
-
volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞
-
volatile 标记的变量不会被编译器优化;synchronized 标记的变量可能会被编译器优化
原子类
我们以AtomicInteger
为例,深入理解原子类的用法和原理:
概述:java从JDK1.5开始提供了java.util.concurrent.atomic包(简称Atomic包),这个包中的原子操作类提供了一种用法简单,性能高效,线程安全地更新一个变量的方式。
因为变量的类型有很多种,所以在Atomic包里一共提供了13个类,属于4种类型的原子更新方式,分别是原子更新基本类型、原子更新数组、原子更新引用和原子更新属性(字段)。
本次我们只讲解使用原子的方式更新基本类型,使用原子的方式更新基本类型Atomic包提供了以下3个类:
AtomicBoolean: 原子更新布尔类型
AtomicInteger: 原子更新整型
AtomicLong: 原子更新长整型
以上3个类提供的方法几乎一模一样,所以本节仅以AtomicInteger为例进行讲解,AtomicInteger的常用方法如下:
public AtomicInteger(): 初始化一个默认值为0的原子型Integer
public AtomicInteger(int initialValue): 初始化一个指定值的原子型Integer
int get(): 获取值
int getAndIncrement(): 以原子方式将当前值加1,注意,这里返回的是自增前的值。
int incrementAndGet(): 以原子方式将当前值加1,注意,这里返回的是自增后的值。
int addAndGet(int data): 以原子方式将输入的数值与实例中的值(AtomicInteger里的value)相加,并返回结果。
int getAndSet(int value): 以原子方式设置为newValue的值,并返回旧值。
其原子性作用前面展示过:
不使用原子类时:
点击查看代码
<details>
<summary>点击查看代码</summary>
package com.vayne.thread;
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) throws InterruptedException {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
//这里规定线程数大于2,一般有GC线程以及main主线程
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(test.inc);
}
}
</details>
使用Atomic原子类
点击查看代码
package com.vayne.thread;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
public class Test {
// public int inc = 0;
public AtomicInteger inc = new AtomicInteger();
public void increase() {
inc.incrementAndGet();
}
public static void main(String[] args) throws InterruptedException {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(test.inc);
}
}
AtomicInteger原理
AtomicInteger的本质:自旋锁 + CAS算法
我们查看Atomic Integer源码发现
// cas算法的实现类
private static final Unsafe unsafe = Unsafe.getUnsafe();
CAS的全称是: Compare And Swap(比较再交换); 是现代CPU广泛支持的一种对内存中的共享数据进行操作的一种特殊指令。CAS可以将read-modify-write转换为原子操作,这个原子操作直接由处理器保证。CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当旧预期值A和内存值V相同时,将内存值V修改为B并返回true,否则什么都不做,并返回false。
举例说明:
- 在内存值V当中,存储着值为10的变量。
- 此时线程1想要把变量的值增加1。对线程1来说,旧的预期值 A = 10 ,要修改的新值 B = 11。
- 在线程1要提交更新之前,另一个线程2抢先一步,把内存值V中的变量值率先更新成了11。
- 线程1开始提交更新,首先进行A和内存值V的实际值比较(Compare),发现A不等于V的值,提交失败。
- 线程1重新获取内存值V作为当前A的值,并重新计算想要修改的新值。此时对线程1来说,A = 11,B = 12。这个重新尝试的过程被称为自旋。
- 这一次比较幸运,没有其他线程改变V的值。线程1进行Compare,发现A和V的值是相等的。
- 线程1进行SWAP,把内存V的值替换为B,也就是12。
而CAS会引发哪些问题呢?
1.ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。
2.循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
3.只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
CAS与Synchronized的比较
CAS和Synchronized都可以保证多线程环境下共享数据的安全性。那么他们两者有什么区别?
Synchronized是从悲观的角度出发:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。因此Synchronized我们也将其称之为悲观锁。jdk中的ReentrantLock也是一种悲观锁。
CAS是从乐观的角度出发:
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。CAS这种机制我们也可以将其称之为乐观锁。
四、常用并发工具类
施工建设中...
热门相关:仙城之王 法医萌妻,撩上瘾! 帝国远征 萌宝来袭:总裁爹地,宠上天 神武觉醒