【高并发趣事二】——JMM及程序中的幽灵

简介: 【高并发趣事二】——JMM及程序中的幽灵

引言


在我们开始写正文之前,我们先看几行代码,各位读者是否能看出问题呢?

第一段:


  public static void main(String[] args) {
        int v1 = 1073741827;
        int v2 = 1431655768;
        System.out.println(v1 + v2);
    }


在各位读者看来,应该输出什么呢?

第二段

public class ThreadTestService {
    int a = 0;
    boolean flag = false;
    public void writer() {
        a = 1;
        flag = true;
        System.out.println(flag);
    }
    public void reader() {
        if (flag) {
            int i = a;
            System.out.println(i);
            //.....
        } else {
            System.out.println("无");
        }
    }
}

假设有两个线程A和B,A线程首先执行writer方法,随后B线程接着执行reader方法,在线程B执行操作时,能否看到线程A操作1对共享变量a的写入呢?


下面我们带着上面两个问题,开始本篇博客的内容


JMM概述:


JMM全称是Java Memory Model (java 内存模型),JMM的关键技术点都是围绕多线程的原子性、可见性、有序性建立的,这也是解决多线程并行机制的环境下,定义出定义一种规则,意在保证多个线程可以有效的、正确的协同工作。


在JAVA中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量,方法定义参数和异常处理参数不会再线程之间共享,他们不会存在内存可见问题,也不受内存模型的影响。


三要素——原子性


原子性是指一个操作是不可中断的,即使是在多个线程一起执行的情况下,一个操作一旦 开始执行,就不会受到其他线程的干扰。


比如,有两个线程同时对一个静态全局变量int num进行赋值,线程A给他赋值为1,线程B给他赋值为2,那么不管这两个线程以何种方式何种步调去执行,num的值最终要么是1要么是2,线程A和线程B在赋值操作期间,是不可能受到对方干扰的,这就是原子性的一个特点——不可被中断。


但如果我们不使用int类型而是用long类型的话,可能就会出现差池了,因为对于32位系统来说,long类型数据的写入不是原子性的(因为long有64位),也就是说,如果两个线程在32位操作系统下同时对一个long类型的数据进行同步操作,那么线程之间的数据操作可能是有干扰的。


三要素——可见性(visbility)


可见性是指在多线程环境下,当一个线程修改了某个 共享变量的值以后,其他线程是否能够立即知道这个修改, 显然,对于串行线程 来说,可见性问题 是不存在的,但是这个问题在并行线程中就会存在了。


20200722174608244.png

如上图,在CPU1和CPU2上个运行一个线程,他们共享变量t,由于编译器优化 或者硬件优化的缘故,在CPU1上的线程将变量t进行了优化,将其缓存在 cache中一个副本,在这个情况下,如果在CPU2的线程修改了t的实际值,那么CPU1上的线程可能无法意识到这个改动,依然会读取cache中的的数据,因此这就产生了可见性问题。


三要素——有序性


有序性问题是比较难理解的一个 问题,对于一个线程执行的代码来说,我们 总习惯性的认为代码总是从前往后依次执行,在单线程环境下确实如此,但是在多线程并发环境下就不见得了,程序的执行可能会出现乱序,给人的感觉就是写在前面的代码,却在后面执行了,造成 这种现象的原因就是:指令重排,重排后的指令未必与原指令顺序一致。


那么这里就会出现一个疑问,为什么会进行指令重排呢,我想大部分读者都可能知道原因,那就是提高性能!!


Happen-before规则


虽然指令重排可以提高性能,但是并不是所有的指令都可以进行重排,重排的前提必须是保证程序能输出正确的结果。下面总结一些规则:


程序执行原则:一个线程内保证语义的串行性。


volatile规则:volatile变量的写操作,必须在读操作前面执行,这保证了volatile变量的可见性。


锁规则:解锁操作必然发生随后的加锁前。


传递性:A先于B,B先于C,那么A必然先于C


线程的start方法先于它的每一个动作


线程的所有操作先于线程的终结 thread.join


线程的中断先于被 中断的线程代码


对象的构造函数执行,结束先于finalize()方法


解答疑惑


通过上面 基本内容的总结,我们现在回过头来看文章开始第二段代码在多线程下环境下出现的问题,就是因为程序在执行的过程 中发生了指令重排。


对于第一段代码,如果我们 在很短的时候内不能看出错误的地方 ,这说明我们基本知识不是很扎实,这段代码的问题是因为 两个数相加超过了int数据类型的范围,所以会出现负数,单独拿出来这个问题 我们可能会很快的想出答案,但是如果这样的代码隐晦的程序出现在我们的复杂的业务逻辑代码中会是什么后果呢?并且这种问题在 测试环境数据量小的情况下还不容易复现,这种没有异常抛出,但是给我们返回了一个错误的执行结果的问题,我们 称之为程序幽灵。


幽灵——并发下的ArrayList


我们都知道ArrayList是一个线程不安全的容器,如果在多线程中使用ArrayList,可能会导致程序出错,看下面代码:


package com.zqf.urgerobot.tools.service;
import java.util.ArrayList;
/**
 * @author zhenghao
 * @description:
 * @date 2020/7/239:41
 */
public class ArrayListMultiThrea {
    static ArrayList<Integer> al = new ArrayList<>();
    public static class addThread implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 100000; i++) {
                al.add(i);
            }
        }
    }
    public static void main(String[] args) throws Exception {
        Thread t1 = new Thread(new addThread());
        Thread t2 = new Thread(new addThread());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(al.size());
    }
}


t1 t2两个线程同时操作同时向一个ArrayList中添加容器。他们各添加10万个元素,因此我们期望的时候最后list中有20万个元素,但是如果我们运行这段代码会有三种结果。


第一、程序一切正常,最后有20万个元素


第二、程序抛出异常


20200723102106262.png


这是 因为在ArrayList在扩容过程中,内部一致性被破坏,但由于没有锁的保护,另外一个线程访问到了不一致的内部状态,导致出现越界问题。


第三、出现了一个非常隐蔽的错误,比如打印的值小于20万。


这是由于多线程访问冲突,使得保存容器大小的变量被多线程不正常访问,同时两个线程也对ArrayList中的同一个位置进行赋值导致的。如果出现这种问题,那么很不幸,你就得到了一个 没有错误提示的错误。并且,他们未必是可以复现的。


幽灵——并发下的HashMap


这个问题我们前面博文已经写过对应的文章《多线程环境下HashMap导致CPU100%》,这里不再啰嗦。


小结


在上文中我们介绍了JMM和一些我们常见的问题,这些问题虽然比较简单,但是当它们混淆在我们业务代码中,一旦线上 发生问题就会我们束手无策,在下一篇博客中介绍我们的送命代码——双重检查锁定及延迟初始化!

目录
相关文章
|
2月前
|
缓存 监控 安全
如何提高 Java 高并发程序的性能?
以下是提升Java高并发程序性能的方法:优化线程池设置,减少锁竞争,使用读写锁和无锁数据结构。利用缓存减少重复计算和数据库查询,并优化数据库操作,采用连接池和分库分表策略。应用异步处理,选择合适的数据结构如`ConcurrentHashMap`。复用对象和资源,使用工具监控性能并定期审查代码,遵循良好编程规范。
|
数据可视化
高并发编程-线程通信_使用wait和notify进行线程间的通信2_多生产者多消费者导致程序假死原因分析
高并发编程-线程通信_使用wait和notify进行线程间的通信2_多生产者多消费者导致程序假死原因分析
55 0
|
Java 调度
【Java|多线程与高并发】 使用Thread 类创建线程的5种方法&&如何查看程序中的线程
多线程编程主要是为了更好地解决并发编程这个问题,因为创建销毁调度一个进程开销比较大(消耗资源多和速度慢),进程之所以开销比较大,主要是在"资源的分配和回收上"而线程也被称为"轻量级进程",因此在解决并发编程这个问题上,线程的创建销毁调度的更快一些.
|
SQL 安全 Java
【高并发趣事三】——双重检查锁定与延迟初始化
【高并发趣事三】——双重检查锁定与延迟初始化
96 0
【高并发趣事三】——双重检查锁定与延迟初始化
【高并发趣事一】——Amdahl(阿姆达尔定律)与Gustafson(古斯塔夫森定律)
【高并发趣事一】——Amdahl(阿姆达尔定律)与Gustafson(古斯塔夫森定律)
367 0
【高并发趣事一】——Amdahl(阿姆达尔定律)与Gustafson(古斯塔夫森定律)
|
IDE Java API
(JAVA高并发程序设计)第二章、java并行程序基础
(JAVA高并发程序设计)第二章、java并行程序基础
214 0
(JAVA高并发程序设计)第二章、java并行程序基础
|
分布式计算 并行计算 算法
【高并发】如何使用Java7提供的Fork/Join框架实现高并发程序?
今天跟大家聊聊如何使用Java7提供的Fork/Join框架实现高并发程序。好了,开始今天的主题吧!
160 0
【高并发】如何使用Java7提供的Fork/Join框架实现高并发程序?
|
存储 供应链 安全
【高并发】信不信?以面向对象的思想是可以写好高并发程序的!
最近,有小伙伴留言,现在大部分开发都是面向对象开发,那如何以面向对象的方式写好并发程序呢?那好,今天我们就来聊聊这个话题。
325 0
|
分布式计算 并行计算 算法
【高并发】如何使用Java7中提供的Fork/Join框架实现高并发程序?
在JDK中,提供了这样一种功能:它能够将复杂的逻辑拆分成一个个简单的逻辑来并行执行,待每个并行执行的逻辑执行完成后,再将各个结果进行汇总,得出最终的结果数据。有点像Hadoop中的MapReduce。 ForkJoin是由JDK1.7之后提供的多线程并发处理框架。ForkJoin框架的基本思想是分而治之。什么是分而治之?分而治之就是将一个复杂的计算,按照设定的阈值分解成多个计算,然后将各个计算结果进行汇总。相应的,ForkJoin将复杂的计算当做一个任务,而分解的多个计算则是当做一个个子任务来并行执行。
159 0
|
缓存 NoSQL 测试技术
大话程序猿眼里的高并发架构
前言 高并发经常会发生在有大活跃用户量,用户高聚集的业务场景中,如:秒杀活动,定时领取红包等。 为了让业务可以流畅的运行并且给用户一个好的交互体验,我们需要根据业务场景预估达到的并发量等因素,来设计适合自己业务场景的高并发处理方案。
1357 0