【Java并发】java内存模型与线程

前言

众所周知,计算机CPU的运算速度远超磁盘IO和网络通信IO,CPU许多时间都消耗在等待IO操作上,那么如何尽可能的让CPU完全的发挥出来呢?在高并发的场景下,又如何让多线程更快的处理每个任务,从而提高服务的TPS?

1 硬件角度思考并发现象

CPU进行计算任务时,往往需要和内存进行交互,比如对运算内容的读取,对运算结果的存储。但是内存的存取速度要远慢于CPU的计算速度,所以一般计算机会在CPU和内存间添加一层高速缓存来作为两者间数据交互的缓冲。这样CPU直接与缓存交互,缓存再将结果同步给内存。

那么问题来了:每个CPU都有一个高速缓存,多个CPU多个缓存对主内存中的同一份数据进行操作,如何保证一致性呢?

好的,既然有这个问题,那么计算机是如何解决的呢?缓存与内存间的交互需要满足一致性协议。如图:

2 Java内存模型 

2.1 主内存与工作内存

Java内存模型面向的是共享变量的访问规则,所以局部变量与方法参数这些线程私有数据,不做考虑,为啥?因为它们很安全呀。所有的共享变量都存在主内存中,每条线程都有自己的工作内存,线程对变量的操作都在工作内存中进行,不能直接与主内存进行交互,如图:

那么工作内存和主内存间的交互协议到底是如何呢?具体有那些操作呢?

操作主内存中的变量:

  • lock:锁定,标识为被某线程独占
  • unlock:解锁,标识为未占用状态
  • read:读取,将主内存变量传输到工作内存,用于load
  • write:写入,将store操作从工作内存传入的值放入主内存的变量

操作工作内存中的变量:

  • load:载入,把read操作从主内存传入的值放到工作内存的变量副本中
  • use:使用,将工作内存变量的值交给处理器
  • assign:赋值,将处理器返回结果赋给工作内存变量
  • store:存储,将工作内存变量传输到主内存,用于write

那么这8项操作需要满足什么规则呢?

  • read-load,store-write,必须成对(不一定连续)出现,即数据从主内存传入工作内存,工作内存必须加载该数据
  • 如果发生了assign(赋值)操作,那么必须执行store(存储),如果没发生,那么不允许执行store
  • use的变量只能来源于load,即变量必须由主内存传过来
  • 一个变量同个时刻只能被一个线程lock,但可以被同一线程lock多次,对应需要unlock同样次数才能解锁
  • 不能unlock一个未被当前线程lock的变量,unlock之前要先同步工作内存中的变量(如果没有assign呢?)

2.2 volatile:可见性与指令不重排

  • 可见性

当一个线程修改了一个被volatile修饰的变量,那么该修改值,其他线程可以立即得知。

实现规则:保证read+load+useassign+store+write两个操作集的原子性

  • 线程执行use操作前的操作必须是read+load操作,即处理器使用变量前必须从主内存获取最新值
  • 线程执行assign操作后的操作必须是store+write操作,即处理器修改变量后必须向主内存同步最新值

不可见的例子(工作内存让人迷茫):

//private static volatile int i;//方法一:添加volatile,让数据可见
private static int i;
private static int threadNum = 100;
private static int addNum = 10000;//方法二:减小addNum次数,比如改为100
private static int expect = threadNum * addNum;

public static void main(String[] args) {
	test01();
	while (i != expect) {
//	    System.out.println(i);//方法三:通过输出i,触发提取i值
//	    try {
//		TimeUnit.MILLISECONDS.sleep(1);//方法四:休眠
//	    } catch (InterruptedException e) {
//		e.printStackTrace();
//	    }
	}
	System.out.println(i);//永远无法触发
}
/**
* 并发修改数据
*/
private static void test01() {
	for (int i = 0; i < threadNum; i++) {
	    new Thread(new Runnable() {
		@Override
		public void run() {
		    for (int i = 0; i < addNum; i++) {
			add();
		    }
		}
	    }).start();
	}
}
/**
* 安全自增
*/
private static void add() {
	synchronized (FutureTaskTest.class) {
	    i++;
	}
}

通过代码中的四种方法,可以恢复正常,推荐方法一,合理有据。

可见性无法保证并发操作线程安全,因为它无法保证操作的原子性,一个操作可以包含多条字节码指令,单条字节指令在编译后也可能包含多个机器码指令,所以上述样例中自增还是添加了synchronized关键字(但是不推荐,性能不好,推荐使用原子类AtomicInteger,下章线程优化会介绍)。

  • 指令不重排

指令重排样例(指令重排让人迷茫):

A线程:
-----------------------------------
a变量的初始化
b=true//处理器先把b值刷新回主内存
-----------------------------------
B线程:
-----------------------------------
if(b){
对a变量进行处理//发现此时还未初始化
}
-----------------------------------

不重排实现规则(前提use或assign顺序与代码顺序保持一致?):

  • 同一线程对不同变量的操作,如果某个变量的use或assign更先发生,那么它的read或write操作也将更先发生,这样代码顺序与指令顺序一致

2.3 原子性,可见性,有序性

  • 原子性

内存模型的八个操作都保证了原子性,基本数据类型的读写也保证了原子性(double和long是允许拆分为两个32位操作,但各个牌子的虚拟机大多都实现了他们操作的原子性),synchronized关键字同样也可以保证操作的原子性。

  • 可见性

除了volatile之外,synchronized和final也可以保证可见性。但是final修饰字段必须保证在构造器中初始化成功后,没有发生this引用逃逸。

this引用逃逸场景:1、构造器中this引用传递出去可被外部线程访问;2、外部类构造器初始化内部类,且内部类访问了外部类数据。危害:外部线程或内部类可能访问到还未完成初始化的变量。

  • 有序性

无序产生的原因:1、指令重排序2、工作内存与主内存数据同步延迟。通过volatile和synchronized可以修复这些问题。

2.4 先行发生(happens-before)原则

该原则是判断数据是否存在竞争、线程是否安全的主要依据。

前提:先行发生针对的是互有依赖的程序,比如都使用同一变量。时间上的先执行不代表先行发生。

  • 程序次序规则:一个线程内,程序控制顺序即先行发生顺序(非同一变量会出现指令重排)
  • 管程锁定规则:同一个锁的unlock先行发生于后面的lock操作。
  • volatile变量规则:volatile变量的写操作先行发生于后面对这个变量的读操作。
  • 线程启动停止规则:start先行发生于此线程的每一个动作,线程所有操作先行发生于终止检测,如Thread.isAlive(),join()
  • 线程中断规则:interrupt()先行发生于中断检测代码,如interrupted()方法
  • 对象终结规则:一个对象初始化完成,先行发生于他的finalize方法开始
  • 传递性规则:A先行发生于B,B先行发生于C,那么......

这些天然先行发生规则之外的操作,都需要进行并发控制。

3 Java与线程

3.1 线程的实现

  • 内核线程实现

内核线程(Kernel-Level Thread):内核通过操纵调度器(Scheduler)对线程进行调度,并映射到CPU上。它提供了轻量级进程(Light Weight Process)来实现线程,与内核线程一对一映射。LWP阻塞不影响整个进程,缺点是需要进行系统调用,需要在用户态和内核态中来回切换,会消耗一定的内核资源,因此系统可支持的LWP数量有限。

  • 用户线程实现

用户线程的建立、同步、销毁和调度完全在用户态中完成,无需内核支持,操作快速且低耗,也可以支持更大的线程数量,部分高性能数据库的多线程就是这么实现。缺陷是线程的创建、切换和调度都需要自己考虑,所以特别复杂,Java和Ruby都尝试失败了。

  • 用户线程+LWP

  • Java线程的实现

在这只提Sun JDK,因为我常用的就是这个,Windows和Linux都是一对一的线程模型,一个Java线程映射一条轻量级进程。

3.2 Java线程调度与状态切换

线程调度即系统为线程分配CPU使用权,它包含两种实现方式:

  • 协同式:由线程本身去控制线程切换,如果一直通知系统切换,则会造成程序阻塞,持续占用CPU
  • 抢占式:由系统来控制线程切换,某个线程阻塞不会造成程序阻塞,Java采用的就是这种方式

线程状态包含五种:

  • 新建(New):创建后尚未运行
  • 运行(Runnable):包含Running和Ready,可能在运行,也可能在等待CPU为它分配执行时间
  • 无限期等待(Waiting):不会被分配CPU执行时间,需等待其他线程唤醒,如:LockSupport.park(),没有设置Timeout参数的Object.wait(),Thread.join()
  • 限期等待(Timed Waiting):不会被分配CPU执行时间,但在等待一定时间后会由系统自动唤醒,如:Thread.sleep(),LockSupport.parkNanos(),LockSupport.parkUntil(),设置Timeout参数的Object.wait(),Thread.join()
  • 阻塞(Blocked):同步场景下,等待获取一个排他锁
  • 结束(Terminated):已终止线程

了解了Java内存模型和线程,那么接下来就要聊聊核心话题:线程安全与锁优化。

 


爱家人,爱生活,爱设计,爱编程,拥抱精彩人生!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

qqchaozai

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值