目录
③BigDecimal BigInteger(不可变、线程安全)
四、共享模型之内存(重点)
前面我们讲的monitor主要关注的是访问共享变量时,保证临界区代码的原子性;
这一部分内容我们将进一步学习共享变量在多线程间的【可见性】问题与多条指令执行时的【有序性】问题;
1.java内存模型
1.CPU多核缓存结构
用一张图简单的描述一下当下计算机的组成,简单的了解一下就行;
CPU缓存为了提高程序运行的性能,现代CPU在很多方面会对程序进行优化。CPU的处理速度是很快的(这里的速度是指读写数据的速度),内存的速度次之,硬盘的速度最慢,在CPU处理内存数据中,内存运行速度太慢,就会拖累CPU的速度,为了解决这样的问题,CPU设计了多级缓存策略;
cup分为三级缓存:每个CPU都有L1,L2,但是L3缓存是多核共用的;
CPU查找数据的顺序为:CPU ->L1 ->L2 ->L3 ->内存 ->硬盘;(读取速度)
从CPU到 | 大约多长时间 |
---|---|
主存 | 60-80纳秒 |
L3catch | 大约15纳秒 |
L2catch | 大约3纳秒 |
L1catch | 大约1纳秒 |
寄存器 | 大约0.3纳秒 |
为了进一步优化,CPU每次读取一个数据,并不是仅仅读取这个数据本身,而是会读取与它相邻的64个字节【之所以是64个字节,这是科学家们测试出来的最优解】的数据,称之为【缓存行】,因为CPU认为,我使用了这个变量很快就会使用与他相邻的数据,这是计算机的局部性原理的体现,这样,就不需要每次都从主存中读取数据了;
缓存是以缓存行为单位,每个缓存行对应着一块内存,一般是 64 byte(8 个 long),缓存的加入会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中(这也叫做缓存行伪共享),CPU 要保证数据的一致性,如果某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效(然后失效的得再去内存中读取最新的数据,这就会降低效率),当然这种问题是被解决了,解决的主要思路就是让不同的cpu核心读取到的缓存行不一致,这样即便一个缓存行被修改也不会影响其他cpu核心的数据。@sun.misc.Contended可以避免伪共享(这里先有个印象就行)
这种多级缓存的结构下,会有什么问题呢?最经典的就是【可见性的问题】,可以简单的理解为,一个线程修改的值对其他线程可能不可见。比如两个CPU读取了一个缓存行,缓存行里有两个变量,一个x一个y。第一颗CPU修改了x的数据,还没有刷回主存,此时第二颗CPU,从主存中读取了未修改的缓存行,而此时第一颗CPU修改的数据刷回主存,这时就出现,第二颗CPU读取的数据和主存不一致的情况。为了解决数据不一致的问题,很多厂商提出了自己的解决方案,比如英特尔的MESI协议。
除了增加高速缓存之外,为了使处理器内部的运算单元尽量被充分利用。处理器可能会对输入的代码进行【乱序执行】,优化处理器会在计算机之后将乱序执行的结构【进行重组】,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句的先后执行顺序与输入输入代码中的顺序一致。因此如果存在一个计算任务,依赖于另外一个依赖任务的中间,结果那么顺序性不能靠代码的先后顺序来保证,Java虚拟机的即时编译器中也有【指令重排】的优化。
我们可以举一个简单的例子来形象的说明一下,比如现在我们有这么一个需求,有4条指令,这4条指令分别是让4个人在4张纸上写下【新年快乐】4个字。但是在这个过程当中,有的人写的快,有的人写的慢,而如果我们非要按照新年快乐这4个顺序去执行这个工作的话,可能时间会浪费的多一点,那我们不妨让这4个人分别去写她们这4个字,我们等着这4个人最后一个人写完了,然后再把这4个字组合在一起,我们就达到目的了,这样的乱序执行效率可能就会更高一些。
2.JMM内存模型
Java虚拟机规范中曾经试图定义一种Java内存模型,来屏蔽各种硬件和操作系统的内存访问之间的差异,以实现让Java程序在各种平台上都能达到一致的内存访问效果,在此之前主流程序语言直接使用物理内存和操作系统的内存模型,会由于不同平台的内存模型的差异,可能导致程序在一套平台上发挥完全正常,而在另一套平台上并发觉经常发生错误,所以在某种常见的场景下,必须针对平台来进行代码的编写。
JMM 即 Java Memory Model,它定义了主存(共享内存)、工作内存(线程私有)抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。 JMM模型当中存在的三个问题:
-
原子性 - 保证指令不会受到线程上下文切换的影响
-
可见性 - 保证指令不会受 cpu 缓存的影响
-
有序性 - 保证指令不会受 cpu 指令并行优化的影响
这个内存模型是为了避免我们Java开发人员直接面对底层,屏蔽的了操作对开发人员的一些影响;
①案例引入-退不出的循环
main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
sleep(1);
run = false; // 线程t不会如预想的停下来
}
为什么呢?分析一下:
-
初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。(主内存是多个线程所共享的,工作内存是线程所独有的)
2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至该线程的工作内存中的高速缓存中, 减少对主存中 run 的访问,提高效率,下一次线程再来访问这个变量就不会去主存中读取了而是直接从自己的工作内存中读取(这里的工作内存是关联了CPU的高速缓存的)
3. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量 的值,结果永远是旧值;
②可见性-解决方案
使用关键字volatile,它可以用来修饰成员变量和静态成员变量,不能修饰局部变量,因为局部变量是线程私有的,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 修饰的变量都是直接操作主存中的该变量;
volatile static boolean run = true;
这里是初步了解volatile关键字的效果,后面会详细讲解volatile的底层原理;
当然sychronized关键字也是可以解决可见性的问题;
static boolean run = true;
final static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
sychronized(lock){
if(!run){
break;
}
}
}
});
t.start();
sleep(1);
sychronized(lock){
run = false;
}
}
但是吧,sychronized是要创建monitor,是属于比较重量级的操作;
但是volatile就更加轻量;所以我们在操作可见性的问题的时候首选volatile关键字;
③可见性VS原子性
前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可 见, 不能保证原子性,仅用在一个写线程,多个读线程的情况: 上例从字节码理解是这样的:
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
putstatic run // 线程 main 修改 run 为 false, 仅此一次
getstatic run // 线程 t 获取 run false
比较一下之前我们将线程安全时举的例子:两个线程一个 i++ 一个 i-- ,volatile只能保证看到最新值,不能解决指令交错;(要解决原子性问题还是乖乖的使用synchronized或者是ReentratLock)
// 假设i的初始值为0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
注意
synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是 synchronized 是属于重量级操作,性能相对更低 。
JMM关于synchronized的两条规定:
1)线程解锁前,必须把共享变量的最新值刷新到主内存中
2)线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新获取最新的值
(注意:加锁与解锁需要是同一把锁)
通过以上两点,可以看到synchronized能够实现可见性。同时,由于synchronized具有同步锁,所以它也具有原子性;
如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,想一想为什么?(println方法中有synchronized代码块保证了可见性)
synchronized关键字不能阻止指令重排,但在一定程度上能保证有序性(如果共享变量没有逃逸出同步代码块的话)。因为在单线程的情况下指令重排不影响结果,相当于保障了有序性。
为什么synchronized无法禁止指令重排,但是仍然可以保证有序性?
加了锁之后,只有一个线程可以获得锁,获得不到锁的线程就需要阻塞等待。所以同一时间只有一个线程在执行,相当于单线程执行,而单线程的指令重排是没有问题的;前提是共享变量没有逃逸出加锁的同步代码块;
④终止模式之两段终止
在一个线程 T1 中如何“优雅”终止线程 T2?这里的【优雅】指的是给 T2 一个料理后事的机会。
前面我们讲过这种模式,只不过当时使用的是interrupt()方法的重置标记来实时监控线程的运行状态;不过有一个缺点就是需要添加很多捕获打断异常的try...catch 来
错误思路;
-
使用线程对象的 stop() 方法停止线程
-
stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁, 其它线程将永远无法获取锁
-
-
使用 System.exit(int) 方法停止线程
-
目的仅是停止一个线程,但这种做法会让整个程序都停止
-
利用 isInterrupted带的标记(有点绕)
interrupt 可以打断正在执行的线程,无论这个线程是在 sleep,wait,还是正常运行;
这段代码有点绕,理解起来有一点难度。
package thread;
import lombok.extern.slf4j.Slf4j;
@Slf4j(topic="c.TwoParseTermination")
class TwoParseTermination {
private Thread monitor;
// 启动线程
public void start() {
monitor = new Thread(() -> {
while (true) {
//拿到当前线程
Thread thread = Thread.currentThread();
// 调用 isInterrupted拿到标记值 这个不会清除标记,并且这个Interrupte的最初标记是false
if(thread.isInterrupted()) {
log.info("料理后事 ...");
break; //终止线程
} else {
try {
Thread.sleep(1000);
log.info("将结果保存 ...");
} catch (InterruptedException e) {
log.info("重置打断标记 ...");
//因为捕获异常后标记值变成了false,这里再打断一下,就相当于把这个正在运行的线程给打断了,所以这里的标记值又变成了ture,如果这里不重置这个标记值那么这个线程是会一直运行的
thread.interrupt();
e.printStackTrace();
}
}
}
}, "monitor");
monitor.start();
}
// 终止线程
public void stop() {
//打断正在正常运行的monitor线程
monitor.interrupt();
}
}
测试代码:
public class TwoParseTerminationTest {
public static void main(String[] args) throws InterruptedException {
TwoParseTermination twoParseTermination = new TwoParseTermination();
twoParseTermination.start();
//让主线程先睡一下
Thread.sleep(3500);
//调用stop 打断正在运行的monitor线程, 但是这里的monitor线程是每执行一次就会睡眠1秒,所以大概率是打断在睡眠的monitor线程,所以会抛出异常并且被捕获 所以会输出日志重置打断标记 ...,并且标记会被设置为ture,然后再调用thread.interrupt();,此时又再次重置标记为ture,然后再被thread.isInterrupted()获取ture来执行while里面的代码,然后while中的代码执行完成后再e.printStackTrace();
twoParseTermination.stop();
}
}
运行结果:
22:03:23 [monitor] c.TwoParseTermination - 将结果保存 ...
22:03:24 [monitor] c.TwoParseTermination - 将结果保存 ...
22:03:25 [monitor] c.TwoParseTermination - 将结果保存 ... //这里到上面主现场在休眠
22:03:26 [monitor] c.TwoParseTermination - 重置打断标记 ...
22:03:26 [monitor] c.TwoParseTermination - 料理后事 ...
java.lang.InterruptedException: sleep interrupted
isInterrupted() 与 interrupted() 比较,如下: 首先,isInterrupted 是实例方法,interrupted 是静态方法,它们的用处都是查看当前打断的状态,但是 isInterrupted 方法查看线程的时候,不会将打断标记清空,也就是置为 false,interrupted 查看线程打断状态后,会将打断标志置为 false,也就是清空打断标记,简单来说,interrupt() 方法类似于 setter 设置中断值,isInterrupted() 类似于 getter 获取中断值,interrupted() 类似于 getter + setter 先获取中断值,然后清除标志。
主要是区分会不会重置线程的标记值;
利用volatile修饰的停止标记
使用volatile后可以直接自己定义标记了,这个自定义的标记也可以保证线程的安全,就不需要使用interrupt和sleep来转换标记了,使用interrupt()确实麻烦,而且容易遗漏一些忘记标记的情况.....
import lombok.extern.slf4j.Slf4j;
@Slf4j(topic = "c.VolatileTest")
public class VolatileTest {
Thread monitor;
// 设置自定义标记,用于判断是否被终止了 默认是false
private volatile boolean stop = false;
//启动监控器线程
public void start() {
// 设置线控器线程,用于监控线程状态
monitor = new Thread() {
@Override
public void run() {
// 开始不停的监控
while (true) {
if(stop) {
log.debug("处理后续任务");
//这个break写不写看自己业务需求,如果不写的话那么线程就会进入死循环
break;
}
try {
log.debug("监控器运行中...");
// 线程休眠
Thread.sleep(1000);
} catch (InterruptedException e) {
log.debug("被打断了");
e.printStackTrace();
}
}
}
};
monitor.setName("monitor");
monitor.start();
}
/**
* 用于停止监控器线程
*/
public void stop() {
// 修改标记
stop = true;
// 打断线程
monitor.interrupt();
}
}
class Test{
public static void main(String[] args) throws InterruptedException {
VolatileTest monitor = new VolatileTest();
monitor.start();
Thread.sleep(3500);
monitor.stop();
}
}
运行结果:
22:39:56 [monitor] c.VolatileTest - 监控器运行中...
22:39:57 [monitor] c.VolatileTest - 监控器运行中...
22:39:58 [monitor] c.VolatileTest - 监控器运行中...
22:39:59 [monitor] c.VolatileTest - 监控器运行中...
22:39:59 [monitor] c.VolatileTest - 被打断了
22:39:59 [monitor] c.VolatileTest - 处理后续任务
java.lang.InterruptedException: sleep interrupted
如果没有break的话那么线程就会一直执行while循环,也就是说这个线程的终止控制权是在程序的编写者手上;
⑤模式之 Balking(犹豫)
Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做 了,直接结束返回,有点类似单例。
-
用一个标记来判断该任务是否已经被执行过了
-
需要避免线程安全问题
-
加锁的代码块要尽量的小,以保证性能
public class Code_03_Test {
public static void main(String[] args) throws InterruptedException {
Monitor monitor = new Monitor();
monitor.start();
monitor.start();
Thread.sleep(3500);
monitor.stop();
}
}
class Monitor {
Thread monitor;
// 设置标记,用于判断是否被终止了
private volatile boolean stop = false;
// 设置标记,用于判断是否已经启动过了 这里不能单纯的使用volatile还是得使用synchronized来加锁,如果这里只使用volatile的话,先进来的线程可能还没来得及修改这个starting标记变量,其他线程就已经执行到后面的代码了
private boolean starting = false;
/**
* 启动监控器线程
*/
public void start() {
// 上锁,避免多线程运行时出现线程安全问题
synchronized (this) {
if (starting) {
// 已经启动过了,直接返回
return;
}
// 启动监视器,改变标记
starting = true;
}
// 设置线控器线程,用于监控线程状态
monitor = new Thread() {
@Override
public void run() {
// 开始不停的监控
while (true) {
if(stop) {
System.out.println("处理后续任务");
break;
}
System.out.println("监控器运行中...");
try {
// 线程休眠
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("被打断了");
}
}
}
};
monitor.start();
}
/**
* 用于停止监控器线程
*/
public void stop() {
// 打断线程
monitor.interrupt();
stop = true;
}
}
应用之单例模式
public final class Singleton {
private Singleton() {
}
private static Singleton INSTANCE = null;
public static synchronized Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
3.有序性(避免指令重排)
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...;
j = ...;
可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是
i = ...;
j = ...;
也可以是
j = ...;
i = ...;
这种特性称之为【指令重排】,多线程下【指令重排会】影响正确性。为什么要有重排指令这项优化呢?从 CPU 执行指令的原理来理解一下;
指令优化
事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?可以想到指令 还可以再划分成一个个更小的阶段,例如,每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回
这 5 个阶段:
在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,这一技术在 80年代 中 到 90年代中 占据了计算架构的重要地位。
这样设计可以提高在cpu指令并行执行的效率;指令重排的前提是,重排指令不能影响结果;
支持流水显得处理器
现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回
的处理 器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一 条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了 指令地吞吐率。
诡异的结果
分析下面的代码:
int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2 执行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}
I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?
正常分析;
情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
但是,结果还有可能是 0 ,信不信?!
为0的这种情况下是:线程2 先执行 ready = true,然后切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2
这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现;
可以借助压测工具来测试,大约是测试几千万次就会出现这种为0 的情况了;
解决方案-volatile
使用volatile 修饰的变量,可以禁用指令重排;并且前面我们还讲了volatile还可以保证变量的有序性;
volatile boolean ready = false;
4.volatile原理(重点)
如何保证可见性
-
写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中;写屏障是加在volatile修饰的变量的赋值之后的
public void actor2(I_Result r) {
num = 2; //这个num也会同步到主存
ready = true; // ready是volatile修饰的变量 ,对该变量进行修改是带写屏障的
// 这里加写屏障 【写屏障之前的代码】是不会指令重排序的,并且写屏障之前的变量即便没有加volatile关键字修饰,那也是会强制从主存中刷新过来的
}
-
而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
public void actor1(I_Result r) {
// 这里加读屏障 读屏障后面的变量都是从主存中读取的
// ready 是 volatile修饰的变量 读取该值是带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
如何保证有序性
-
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
-
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
还是那句话,不能解决指令交错:
-
写屏障仅仅是保证之后的读能够读到最新的结果,但是不能保证其他线程的读取操作跑到它前面去
-
而有序性的保证也只是保证了本线程内相关代码不被重排序,至于两个线程间的代码的执行那就是cpu来控制的了
而synchronized就可以同时保证代码的可见性,有序性和原子性,但是是不能禁止指令重排的;
synchronized关键字不能阻止指令重排,但在一定程度上能保证有序性(如果共享变量没有逃逸出同步代码块的话)。因为在单线程的情况下指令重排不影响结果,相当于保障了有序性。
为什么synchronized无法禁止指令重排,但是仍然可以保证有序性?
加了锁之后,只有一个线程可以获得锁,获得不到锁的线程就需要阻塞等待。所以同一时间只有一个线程在执行,相当于单线程执行,而单线程的指令重排是没有问题的;前提是共享变量没有逃逸出加锁的同步代码块;
5.double-checked locking问题
以著名的单例为案例:
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) { //这里之所以要判断,那是为了保证后面的同步代码块以后只被执行一次就不再重复执行了
// 首次访问会同步,而之后的使用没有再使用 synchronized,前面if的功能 可以提高性能
synchronized(Singleton.class) {
if (INSTANCE == null) { // 这个if是为了保证不被外界new多个对象,保证该对象是单例的
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
以上的实现特点是:
-
懒惰实例化
-
首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
-
有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外(那就可能会导致这个变量和其他指令进行重排)
但在多线程环境下,上面的代码是有问题的,getInstance 方法对应的字节码为:
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
// ldc是获得类对象
6: ldc #3 // class cn/itcast/n5/Singleton
// 复制操作数栈栈顶的值放入栈顶, 将类对象的引用地址复制了一份
8: dup
// 操作数栈栈顶的值弹出,即将对象的引用地址存到局部变量表中
// 将类对象的引用地址存储了一份,是为了将来解锁用
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
// 表示创建对象,将对象引用入栈 // new Singleton
17: new #3 // class cn/itcast/n5/Singleton
// 表示复制一份对象引用
20: dup
// 通过这个复制的引用调用它的构造方法
21: invokespecial #4 // Method "<init>":()V
// 利用一个对象引用,赋值给 static INSTANCE
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
其中
-
17 表示创建对象,将对象引用入栈 // 相当于Java中的new Singleton
-
20 表示复制一份对象引用 // 复制了引用地址
-
21 表示利用一个对象引用,调用构造方法 // 根据复制的引用地址调用构造方法
-
24 表示利用一个对象引用,赋值给 static INSTANCE
也许 jvm 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行:
关键在于 0: getstatic 这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取 INSTANCE 变量的值
这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例 ;
对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效;
解决方案:
public final class Singleton {
private Singleton() { }
//加上 volatile
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
// 实例没创建,才会进入内部的 synchronized代码块
if (INSTANCE == null) {
synchronized (Singleton.class) { // t2
// 也许有其它线程已经创建实例,所以再判断一次
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
字节码上看不出来 volatile 指令的效果,这里我们从读,写屏障的角度来分析:
-
可见性
-
写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
-
而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
-
-
有序性
-
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
-
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
-
-
更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性
习题
balking 模式习题 :
希望 doInit() 方法仅被调用一次,下面的实现是否有问题,为什么?
public class TestVolatile {
volatile boolean initialized = false;
void init() {
if (initialized) {
return;
}
doInit();
initialized = true;
}
private void doInit() {
}
}
上面的代码是不对的,虽然使用了volatile来修饰initialized这个变量,但是这个变量在多行代码中都使用了,由于volatile不能保证代码的原子性,就可能会出现下面的情况,开始线程1执行代码,发现initialized是false,然后执行doInit()方法,线程1刚开始准备把initialized的值修改为ture,此时线程2也进来执行代码了,线程2发现initialized还是false,所以线程2也调用了一次doInit();方法;
所以还是得使用synchronized来保证代码的原子性;
volatile这个关键字别乱使用,volatile适用于一个线程读,一个线程写的时候来保证变量的可见性;
保证synchronized代码块外面的变量不进行指令重排序和变量的可见性;
五、共享模型之无锁
1.问题提出
使用monitor 是阻塞式的悲观锁实现并发控制,这章我们将通过非阻塞式的乐观锁的来实现并发控制;
2.CAS(重点)
在jdk11之后compare and swap 改为了compare and set ; 这个方法在unsafe类;
cas的核心思想:(cas是基于cpu支持才实现的)
把栈中运算处理后的数据 对比 期望值(这个期望值就是我们从主存中取的那个原始值) 与 此时主存中的值是否一致(这个比较并重置数据的操作是cpu帮我们保证了这一系列操作的原子性),如果一致就对主存的数据进行刷新操作,如果不一致就刷新失败,然后读取主存里面的数据,再次取比较新的期望值和主存值,如果还不一致那么继续这个操作(通过自旋一直尝试),直到成功为止;
或者这样理解:
就是给一个元素赋值的时候,先看看(主存)内存中的那个值到底有没有变,如果没变我就修改,如果变了我就不修改,其实这是一个无锁的过程,不需要挂起线程,无锁的思路就是先尝试,如果失败了,就进行补偿,也就是你可以继续尝试。这样在【少量线程竞争】的情况下能很多程度的提高性能;
使用一段伪代码来模拟一下cpu的这些操作:
package lock;
/**
* 通过代码模拟一下 cpu 是如何实现cas操作的
*/
public class AtomicTest {
//这个是模拟主存中的值 需要使用volatile来修饰
public static volatile int count; //怎么感觉这个变量没有被初始化,那后面是怎么进入这个if中的?这个count的初始值是0......
//这里之所以使用了synchronized是为了下面的比较和再次赋值具有原子性,cas中的原子性保证,cpu帮我们实现了,所以这里只是模拟一下
public synchronized static boolean compareAndSwap(int expect,int update){
//比较期望值是否和内存存储的值是否一致
if (expect == count){
//更新内存中的值
count = update;
return true;
}
return false;
}
public static void main(String[] args) {
for (int i = 0; i < 500; i++) {
new Thread(new Runnable() {
@Override
public void run() {
boolean flag = false;
//自旋尝试
while (!flag){
//我们期望这个count还是内存的count,就是我自己(线程)在使用的时候,这个内存中的count还没有被别的线程给刷新。
//没有被别人给刷新那么自己就可以把内存中的数据给刷新了;然后把这个过程放在一个while 循环中
//如果失败了,就让这个线程一直尝试,直到成功为止,这个就和自旋差不多
flag = compareAndSwap(count,count+1); //这里的count是0
}
}
}).start();
}
}
}
这个案例其实有些不恰当,我们想做的是在赋值阶段,可以通过尝试比较预期值的方式来判断是否能修改当前值,但事实上还是使用了synchronized,这脱离了CAS在计算机底层也是三个动作的初衷,【取值】【比较】【赋值】,只不过这三个动作是CPU原语级别的原子动作,不需要我们程序员担心;
3.volatile与CAS
获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。 CAS需要和volatile配合起来使用才能保证无锁操作的成功;它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取 它的值,线程操作 volatile 变量都是直接操作主存。即一个线程对 volatile 变量的修改,对另一个线程可见。
注意
volatile 仅仅保证了共享变量的可见性,让其它线程能够看到最新值,但不能解决指令交错问题(不能保证原 子性)
CAS 必须借助 volatile 才能读取到共享变量的最新值来实现【比较并交换】的效果。
为什么无锁效率高 ?
-
无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,类似于自旋。而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。线程的上下文切换是费时的,在重试次数不是太多时,无锁的效率高于有锁。
-
线程就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火, 等被唤醒又得重新打火、启动、加速... 恢复到高速运行,代价比较大
-
但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑 道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。所以总的来说,当线程数小于等于cpu核心数时,使用无锁方案是很合适的,因为有足够多的cpu让线程运行。当线程数远多于cpu核心数时,无锁效率相比于有锁就没有太大优势,因为依旧会发生上下文切换。
CAS的特点
结合 CAS 和 volatile 可以实现无锁并发,适用于线程数少、多核 CPU 的场景下。
-
CAS 是基于【乐观锁】的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再 重试呗。
-
synchronized 是基于【悲观锁】的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想 改,我改完了解开锁,你们才有机会。
-
CAS 体现的是【无锁并发、无阻塞并发】,请仔细体会这两句话的意思
-
因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
-
但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
-
4.使用实现了CAS的工具类
①原子整数(重点)
J.U.C (java.util.concurrent.atomic)并发包中提供了一些并发工具类:
-
AtomicBoolean
-
AtomicInteger
-
AtomicLong
以 AtomicInteger 为例:
package juc;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @author LJM
* @create 2022/5/2
*/
public class AtomicIntegerTest {
public static void main(String[] args) {
//通过有参构造的AtomicInteger对象,传进去的参数是会赋值给value,这个value是AtomicInteger里面维护的一个值
//并且这个value是被volatile给修饰的,这个value是内存中最新的值(由volatile保证)
AtomicInteger atomicInteger = new AtomicInteger(0);
//这个参数的第一个值是期望值,会和value(通过atomicInteger对象对value进行初始化操作)进行对比,如果相同才会把update的更新值刷新给内存(就是去更新value)
//atomicInteger.compareAndSet(1,2);
//但是吧,这样的话我们还需要直接写while循环来进行判断,有点小麻烦,所以我们一般是使用相关的封装好的类
//先自增并且获取值,相当于 ++i ,只不过这是原子性的操作
System.out.println(atomicInteger.incrementAndGet()); //++i
//先获取再自增, 相当于 i++ ,只不过这是原子性的操作
System.out.println(atomicInteger.getAndIncrement());//i++ 虽然这里的输出是1,但是内存中的value已经变成2了
System.out.println(atomicInteger.get()); //获取内存中的value 这里打印的是2
//获取并且增加,具体增加多少,你传的参数说了算
int andAdd = atomicInteger.getAndAdd(5); //获取到的是2,然后把内存中的value加5,此时的value变成了7
System.out.println(andAdd);
//先增加再获取
int i = atomicInteger.addAndGet(5);//获取的是12,打印的也是12
System.out.println(i);
//还有 i-- --i 这个类提供的方法都是原子性操作的
//进行乘法或者是复杂的计算 我们发现IntUnaryOperator是一个函数式接口,所以这里面传的参数可以传一个lambda表达式
atomicInteger.updateAndGet(x -> x*10); //这里面的x代表的就是AtomicInteger里面的value值
//不过需要注意的是,如果其他线程修改了value值,那么这个乘法或者是复杂运算就需要对新的value进行重新计算
//这个和上面差不多只不过是先获取value值然后再更新value,最后得到的是没有进行运算的value值
System.out.println(atomicInteger.getAndUpdate(y->y*10) );//这里输出120,然后此时内存中的value变成了1200
System.out.println(atomicInteger.get());//打印1200
}
}
②原子引用
为什么需要原子引用类型?
-
AtomicReference
-
AtomicMarkableReference
-
AtomicStampedReference
实际开发的过程中我们使用的不一定是int、long等基本数据类型,也有可能时BigDecimal这样的类型,这时就需要用到原子引用作为容器。原子引用设置值使用的是unsafe.compareAndSwapObject()
方法。原子引用中表示数据的类型需要重写equals()
方法。
案例演示:
public interface DecimalAccount {
// 获取余额
BigDecimal getBalance();
// 取款
void withdraw(BigDecimal amount);
/**
* 方法内会启动 1000 个线程,每个线程做 -10 元 的操作
* 如果初始余额为 10000 那么正确的结果应当是 0
*/
static void demo(DecimalAccount account) {
List<Thread> ts = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
ts.add(new Thread(() -> {
account.withdraw(BigDecimal.TEN); //BigDecimal.TEN 表示10元
}));
}
ts.forEach(Thread::start);
ts.forEach(t -> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(account.getBalance());
}
}
实现:
class DecimalAccountSafeCas implements DecimalAccount {
AtomicReference<BigDecimal> ref; //创建原子引用对象
public DecimalAccountSafeCas(BigDecimal balance) {
this.ref = new AtomicReference<>(balance);
}
@Override
public BigDecimal getBalance() {
return ref.get();
}
@Override
public void withdraw(BigDecimal amount) {
//主要还是cas的思想
while (true) {
BigDecimal prev = ref.get(); //从主存中获取到的最新值
BigDecimal next = prev.subtract(amount); //subtract表示做减法
if (ref.compareAndSet(prev, next)) { //这个ref应该是内存中的值,但是这个内存中的该值怎么变化的呀?有点不是很理解。。因为也没看见代码指令来操作它。。 应该是本地方法或者是cpu语义中实现了。
break;
}
}
}
}
测试代码:
//使用BigDecimal最好是在构造中传字符串,这样可以保证精度问题
DecimalAccount.demo(new DecimalAccountSafeCas(new BigDecimal("10000")));
/**
*Object var1 你要修改哪个对象的成员变量
*long offset 这个值在这个对象中的偏移量
*Object expected 期望值
*Object x 实际值
*/
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
③ABA问题以及解决(知道)
ABA问题。当一个线程执行cas操作,尚未修改为新值之前,内存中的值已经被其他线程连续修改了两次,是变量经历了A->B->A的过程(基础数据类型对这个过程无感知,没有影响;但是对于引用数据类型就会有影响了,比如一个线程要刷新内存中的一个对象,但是这个对象中的某个属性已近被另一个线程给修改了,然后你这个线程还没有发现,因为引用数据类型时通过地址引用来指向的)。绝大部分场景我们对ABA不敏感。
解决方案:添加版本号作为标识,每次修改变量值时,对应增加版本号;做cas操作前需要校验版本号。jdk1.5之后,新增了AtomicStampedReference 类来处理这种情况。
如果单纯的使用AtomicReference类那么这个类是不能感知ABA问题的,要使用AtomicStampedReference ;
AtomicStampedReference :
Stamped原意是时间戳的戳,这里是版本号的意思;
对上面的案例进行修改:
//这个AtomicStampedReference对象是没有get方法的,需要使用getReference()来获取内存中的最新值
static AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);//这个0是版本号
public static void main(String[] args) throws InterruptedException {
log.debug("main start...");
// 获取值 A
String prev = ref.getReference();
// 获取版本号
int stamp = ref.getStamp();
log.debug("版本 {}", stamp);
// 如果中间有其它线程干扰,发生了 ABA 现象
other();
sleep(1);
// 尝试改为 C 获取值得时候就需要改变版本号了,每次操作成功就对版本号进行加一操作,对所有线程都要做这种要求
log.debug("change A->C {}", ref.compareAndSet(prev, "C", stamp, stamp + 1));
}
private static void other() {
new Thread(() -> {
log.debug("change A->B {}", ref.compareAndSet(ref.getReference(), "B",
ref.getStamp(), ref.getStamp() + 1));
log.debug("更新版本为 {}", ref.getStamp());
}, "t1").start();
sleep(0.5);
new Thread(() -> {
log.debug("change B->A {}", ref.compareAndSet(ref.getReference(), "A",
ref.getStamp(), ref.getStamp() + 1));
log.debug("更新版本为 {}", ref.getStamp());
}, "t2").start();
}
运行结果:
c.Test36 [main] - main start...
c.Test36 [main] - 版本 0
c.Test36 [t1] - change A->B true
c.Test36 [t1] - 更新版本为 1
c.Test36 [t2] - change B->A true
c.Test36 [t2] - 更新版本为 2
c.Test36 [main] - change A->C false
AtomicStampedReference 可以给原子引用加上版本号,追踪原子引用整个的变化过程,如: A -> B -> A -> C
,通过AtomicStampedReference,我们可以知道,引用变量中途被更改了几次。
但是有时候,并不关心引用变量更改了几次,只是单纯的关心是否更改过,所以就有了AtomicMarkableReference,这个是使用布尔值来记录你有没有更改过;用法与前面的差不多;
④原子数组
有时候我们多个线程就是想修改一下数组里面的元素,并不想修改你这个数组的引用地址,这个时候Atomicreference就没有办法做到了,所以引入了原子数组;
-
AtomicIntegerArray
-
AtomicLongArray
-
AtomicReferenceArray
有如下方法:
/**
这4个参数都是使用函数式表达式表达的;
参数1,提供数组、可以是线程不安全数组或线程安全数组
参数2,获取数组长度的方法
参数3,自增方法,回传 array, index
参数4,打印数组的方法
*/
// supplier 提供者 无中生有 ()->结果 这个函数没有参数但是需要你提供一个返回结果
// function 函数 一个参数一个结果 (参数)->结果 , BiFunction (参数1,参数2)->结果
// consumer 消费者 一个参数没结果 (参数)->void, BiConsumer (参数1,参数2)-> 没有结果
private static <T> void demo(
Supplier<T> arraySupplier,
Function<T, Integer> lengthFun,
BiConsumer<T, Integer> putConsumer,
Consumer<T> printConsumer ) {
List<Thread> ts = new ArrayList<>();
T array = arraySupplier.get();
int length = lengthFun.apply(array);//获取长度
//模拟多个线程来操作数组
for (int i = 0; i < length; i++) {
// 每个线程对数组作 10000 次操作
ts.add(new Thread(() -> {
for (int j = 0; j < 10000; j++) {
putConsumer.accept(array, j%length);
}
}));
}
ts.forEach(t -> t.start()); // 启动所有线程
ts.forEach(t -> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}); // 等所有线程结束
printConsumer.accept(array);
}
测试代码:
public static void main(String[] args) throws InterruptedException {
demo(
() -> new int[10],
(array) -> array.length,
(array, index) -> array[index]++,
(array) -> System.out.println(Arrays.toString(array))
);
TimeUnit.SECONDS.sleep(1);
//使用原子数组
demo(
() -> new AtomicIntegerArray(10),
(array) -> array.length(),
(array, index) -> array.getAndIncrement(index),
(array) -> System.out.println(array)
);
}
结果:
[7870, 7867, 7936, 7889, 7881, 7850, 7859, 7869, 7857, 7862]//随机的,但是这个是明显的线程不安全
[10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000]
⑤字段更新器
这个字段更新器可以保证多个线程访问对象的时候保证对象的成员变量的安全;
-
AtomicReferenceFieldUpdater // 域 字段
-
AtomicIntegerFieldUpdater
-
AtomicLongFieldUpdater 利用字段更新器,可以针对对象的某个域(Field)进行原子操作,只能配合 volatile 修饰的字段使用,否则会出现异常
public class Test {
//构造器中传的三个参数是 要保护的对象类型 第二个参数是保护字段的数据类型 第三个是保护的变量名
public static AtomicReferenceFieldUpdater ref =
AtomicReferenceFieldUpdater.newUpdater(Student.class, String.class, "name");
public static void main(String[] args) throws InterruptedException {
Student student = new Student();
new Thread(() -> {
System.out.println(ref.compareAndSet(student, null, "list"));
}).start();
//ref表示内存中的值,参数一表示你要修改哪个对象 参数2表示原始值 第三个参数表示cas成功后把值更新为该值
System.out.println(ref.compareAndSet(student, null, "张三")); //成功会返回ture
System.out.println(student); //看输出的对象的name是不是被更新为张三,cas成功就会把这个name改为张三,如果在此之前name被其他线程修改了,那么cas就会失败
}
}
class Student {
//这里必须配合volatile来使用
volatile String name;
@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
'}';
}
}
⑥原子累加器LongAdder
public static void main(String[] args) {
for(int i = 0; i < 5; i++) {
demo(() -> new AtomicLong(0), (adder) -> adder.getAndIncrement());
}
for(int i = 0; i < 5; i++) {
demo(() -> new LongAdder(), (adder) -> adder.increment());
}
}
private static <T> void demo(Supplier<T> supplier, Consumer<T> consumer) {
ArrayList<Thread> list = new ArrayList<>();
T adder = supplier.get(); //定义泛型
// 4 个线程,每人累加 50 万 这里使用循环是为了获取到比较真实的执行结果,因为JVM对重复执行的代码的编译是有优化的
for (int i = 0; i < 4; i++) {
list.add(new Thread(() -> {
for (int j = 0; j < 500000; j++) {
consumer.accept(adder);
}
}));
}
long start = System.nanoTime();
list.forEach(t -> t.start());
list.forEach(t -> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
long end = System.nanoTime();
//这里使用的时间单位是纳秒
System.out.println(adder + " cost:" + (end - start)/1000_000);
} public static void main(String[] args) {
for(int i = 0; i < 5; i++) {
demo(() -> new AtomicLong(0), (adder) -> adder.getAndIncrement());
}
for(int i = 0; i < 5; i++) {
demo(() -> new LongAdder(), (adder) -> adder.increment());
}
}
private static <T> void demo(Supplier<T> supplier, Consumer<T> consumer) {
ArrayList<Thread> list = new ArrayList<>();
T adder = supplier.get(); //定义泛型
// 4 个线程,每人累加 50 万 这里使用循环是为了获取到比较真实的执行结果,因为JVM对重复执行的代码的编译是有优化的
for (int i = 0; i < 4; i++) {
list.add(new Thread(() -> {
for (int j = 0; j < 500000; j++) {
consumer.accept(adder);
}
}));
}
long start = System.nanoTime();
list.forEach(t -> t.start());
list.forEach(t -> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
long end = System.nanoTime();
//这里使用的时间单位是纳秒
System.out.println(adder + " cost:" + (end - start)/1000_000);
}
执行代码后,发现使用 LongAdder 比 AtomicLong 快3倍左右,使用 LongAdder 性能提升的原因很简单,就是在有竞争时,设置多个累加单元(但不会超过cpu的核心数),Therad-0 累加 Cell[0],而 Thread-1 累加Cell[1]… 最后将结果汇总。这样它们在累加时操作的不同的 Cell 变量,因此减少了 CAS 重试失败,从而提高性能。
LongAdder的源码和原理分析,以后需要到了或者是自己水平够了才来学了。。。。。。。。。。TODO
5.Unsafe(熟悉)
前面我们学的CAS,LockSupport里面的park()和unPark()以及使用的原子类的cas操作都是调用底层的Unsafe类;
Unsafe 对象提供了非常底层的,操作内存、线程的方法,Unsafe 对象不能直接调用(因为它是类的私有的静态变量),只能通过反射获得。jdk8直接调用Unsafe.getUnsafe()
获得的unsafe不能用。因为这个类操作的东西是比较底层的了,所以它不建议我们开发人员直接使用这个类;
①unsafe对象的获取
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
// Unsafe 使用了单例模式,unsafe 对象是类中的一个私有的变量
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
//设置允许访问私有变量
theUnsafe.setAccessible(true);
Unsafe unsafe = (Unsafe)theUnsafe.get(null);//因为这个unsafe对象是静态的,静态成员变量从属于类不从属于对象,所以这里传个null就行
}
②Unsafe CAS操作
public class Test {
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
// 创建 unsafe 对象
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
Unsafe unsafe = (Unsafe)theUnsafe.get(null);
// 获取域的偏移地址 参数传你要获取的具体的域
long idOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("id"));
long nameOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("name"));
// 进行 cas 操作
Teacher teacher = new Teacher();
//四个参数分别为: 要操作的对象 你要操作对象的的偏移量 获取得旧值 修改的新值
unsafe.compareAndSwapLong(teacher, idOffset, 0, 1);
unsafe.compareAndSwapObject(teacher, nameOffset, null, "张三");
//这里就省略了while循环 如果没有while循环的话那么失败一次这个方法就会结束了
System.out.println(teacher);
}
}
@Data
class Teacher {
private volatile int id;
private volatile String name;
}
③模拟实现原子整数
public class Test {
public static void main(String[] args) {
Account.demo(new MyAtomicInteger(10000));
}
}
class MyAtomicInteger implements Account {
private volatile Integer value;
private static final Unsafe UNSAFE = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = UNSAFE.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
//为value赋值的构造方法
public MyAtomicInteger(Integer value) {
this.value = value;
}
public Integer getValue() {
return value;
}
public void decrement(Integer amount) {
//使用unsafe实现CAS
while (true) {
Integer preVal = this.value;
Integer nextVal = preVal - amount;
if(UNSAFE.compareAndSwapObject(this, valueOffset, preVal, nextVal)) {
break;
}
}
}
@Override
public Integer getBalance() {
return getValue();
}
@Override
public void withdraw(Integer amount) {
decrement(amount);
}
}
六、共享模型之不可变
1.日期转换问题
由于 SimpleDateFormat 是线程不安全的,在多个线程访问的时候就会出现问题:
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
log.debug("{}", sdf.parse("1951-04-21"));
} catch (Exception e) {
log.error("{}", e);
}
}).start();
}
运行上面的代码,有很大几率出现 java.lang.NumberFormatException 或者出现不正确的日期解析结果的异常;
①解决方案-加锁
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 50; i++) {
new Thread(() -> {
synchronized (sdf) {
try {
log.debug("{}", sdf.parse("1951-04-21"));
} catch (Exception e) {
log.error("{}", e);
}
}
}).start();
}
②解决方案-不可变类
如果一个对象在不能够修改其内部状态(属性),那么它就是线程安全的,因为不存在并发修改!这样的对象在 Java 中有很多,例如在 Java 8 后,提供了一个新的日期格式化类:
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd"); //这里是调用它的工厂来创建对象
for (int i = 0; i < 10; i++) {
new Thread(() -> {
LocalDate date = dtf.parse("2018-10-01", LocalDate::from);
log.debug("{}", date);
}).start();
}
不可变对象,实际是另一种避免竞争的方式。
2.不可变类的设计
String类的设计
String 类也是不可变的;
public final class String //这里加final是为了不被继承,所以压根就没有子类来破坏它的内部安全
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[]; //在jdk8之前都是char数组
/** Cache the hash code for the string */
private int hash; // Default to 0 这里是私有的,并且这个hash是没有对外界提供set方法,所以是线程安全的
// ...
}
说明:
-
将类声明为final,避免被子类破坏了不可变性。声明为final后压根就没有子类!
-
将字符数组声明为final,避免被修改,但是可能会有人觉得数组里面的内容可能是不安全的,所以jdk使用的是数组拷贝的形式,就是我【复制一个新的数组出来】,这样就不会被外部的char数组改变而破坏了;
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length); //保护性的拷贝
}
-
hash虽然不是final的,但是其只有在调用
hash()
方法的时候才被赋值,除此之外再无别的方法修改。
发现该类、类中所有属性都是 final 的
-
属性用 final 修饰保证了该属性是只读的,不能修改
-
类用 final 修饰保证了该类中的方法不能被覆盖,防止子类无意间破坏不可变性
保护性拷贝
但有人可能会说,使用字符串时,也有一些跟修改相关的方法啊,比如 substring 等,那么下面就看一看这些方法是如何实现的,就以 substring 为例:
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
发现其内部是调用 String 的构造方法创建了一个新字符串,再进入这个构造看看,是否对 final char[] value 做出 了修改:
public String(char value[], int offset, int count) {
if (offset < 0) {
throw new StringIndexOutOfBoundsException(offset);
}
if (count <= 0) {
if (count < 0) {
throw new StringIndexOutOfBoundsException(count);
}
if (offset <= value.length) {
this.value = "".value;
return;
}
}
if (offset > value.length - count) {
throw new StringIndexOutOfBoundsException(offset + count);
}
this.value = Arrays.copyOfRange(value, offset, offset+count);
}
结果发现也没有,构造新字符串对象时,会生成新的 char[] value,对内容进行复制 。这种通过创建副本对象来【避免共享】的手段称之为【保护性拷贝(defensive copy)】
3.享元模式(重点)
虽然string等不可变类使用了保护性拷贝来避免了资源的共享,但是这也带来了一个问题,那就是频繁的创建对象会占用大量的内存;
为了解决这个问题,我们一般会帮这种不可变类关联一个设计模式---享元设计模式
当需要重用数量有限的同一类对象时 ;
定义 英文名称:Flyweight pattern. 当需要重用数量有限的同一类对象时
wikipedia: A flyweight is an object that minimizes memory usage by sharing as much data as possible with other similar objects
出自 "Gang of Four" design patterns
归类 Structual patterns
①体现---包装类(易踩坑)
在JDK中 Boolean,Byte,Short,Integer,Long,Character 等包装类提供了 valueOf 方法,例如 Long 的 valueOf 会缓存 -128~127 之间的 Long 对象,在这个范围之间会重用对象,大于这个范围,才会新建 Long 对 象:
源码:
public static Long valueOf(long l) {
final int offset = 128;
if (l >= -128 && l <= 127) { // will cache
return LongCache.cache[(int)l + offset];
}
return new Long(l);
}
注意:
Byte, Short, Long 缓存的范围都是 -128~127
Character 缓存的范围是 0~127
Integer的默认范围是 -128~127 (用得最多的)
最小值不能变
但最大值可以通过调整虚拟机参数
-Djava.lang.Integer.IntegerCache.high
来改变Boolean 缓存了 TRUE 和 FALSE
②string字符串(不可变、线程安全)
比如string的常量池;
③BigDecimal BigInteger(不可变、线程安全)
但是需要注意的是,这些不可比变类的单个方法的使用是线程安全的,但是这些方法的组合使用就不能保证是原子性的了,所以在对不可变类的方法的组合使用的时候,还是需要考虑线程安全问题;
④finale变量的原理(有坑)
设置 final 变量的原理
理解了 volatile 原理,再对比 final 的实现就比较简单了;
public class TestFinal {
final int a = 20;
}
对应的字节码:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: bipush 20
7: putfield #2 // Field a:I
<-- 加了写屏障
10: return
发现 final 变量的赋值也会通过 putfield 指令来完成,同样在这条指令之后也会加入写屏障,这样对final变量的写入不会重排序到构造方法之外,保证在其它线程读到 它的值时不会出现为 0 的情况。普通变量不能保证这一点了。
读取final变量原理
获取加了final的变量会从栈内存(常量池)中去获取该值,获取不加final的变量会从共享内存(堆内存)中来获取该值;前者的效率要比后者大;
代码演示:
public class Main {
public static void main(String[] args) {
String a = "xiaomeng2";
final String b = "xiaomeng";
String d = "xiaomeng";
String c = b + 2;
String e = d + 2;
System.out.println((a == c));
System.out.println((a == e));
}
}
这段代码的输出结果是什么呢?
答案是: true 和 false
原因:
变量a指的是字符串常量池中的 xiaomeng2;
【变量 b 是 final 修饰的,变量 b 的值在编译时候就已经确定了它的确定值,换句话说就是提前知道了变量 b 的内容到底是个啥,相当于一个编译期常量;】
变量 c 是 b + 2得到的,由于 b 是一个常量,所以在使用 b 的时候直接相当于使用 b 的原始值(xiaomeng)来进行计算,所以 c 生成的也是一个常量,【a 是常量,c 也是常量,都是 xiaomeng2 而 Java 中常量池中只生成唯一的一个 xiaomeng2 字符串,所以 a 和 c 是相等的!】
d 是指向常量池中 xiaomeng,但由于 d 不是 final 修饰,也就是说在使用 d 的时候不会提前知道 d 的值是什么,所以在计算 e 的时候就不一样了,e的话由于使用的是 d 的引用计算,变量d的访问却需要在运行时通过链接来进行,所以这种计算【会在堆上】生成 xiaomeng2 ,所以最终 e 指向的是【堆上的 xiaomeng2】 , 所以 a 和 e 不相等。
总得来说就是:a、c是常量池的xiaomeng2,e是堆上的xiaomeng2
案例的链接:深入理解final关键字(详解)_weihubeats的博客-CSDN博客_final关键字
string的操作的一个坑:
string底层的 +拼接以及==和equals经常容易在大厂面试中问到!
String A = "a"+"b"; 此时只生成一个对象,因为右边拼接的都是已经确定的常量了,这里使用的是明文拼接(JVM的优化),就是直接把a和b加一起了 然后变成ab,然后new了一个string的对象A来接收ab这个值
String B = "a"; //这是常量,是放在常量池里面的
String C = "b";
String D = B+C; // 这一行代码是产生了一个对象,就是D对象;
下面讲的是字节码指令级别(jvm的一种优化操作):这里+好是在字节码级别被优化成stringBuilder的append方法给拼接上去的,然后产生了一个stringBuilder对象 这个操作是先把a放在一个stringBuilder对象,然后调用append方法拼接b,最后直接返回这个拼接好的stringBUlider对象,再然后这个stringBuilder对象被toString转换string对象 ,我们在回答的时候只需要回答创建了一个string对象D就行;
String ab = "ab";
A==ab ? 输出 ture //并且string重写了object的equals方法,equals比较的是对象的内容
D==ab ? 输出 false
String str = new String("abc"); //这里一共产生了两个对象,一个是"abc"对象,一个是new出来的"abc"对象,前者是在字符串常量池,后者是在堆中
String test = "test"; //产生一个对象或者是零个对象,会先去常量池中找,如果有则不会再创建对象了,如果没有的话那就创建对象,然后会把这个对象存在常量池