java多线程:基础详解

目录
  • Java内存模型
  • 主内存和工作内存的交互命令
  • 内存模型的原子性
  • 内存模型的可见性
  • 内存模型的有序性
  • 指令重排优化的底层原理
  • valatile原理
  • volatile与加锁的区别
  • 先行发生原则
  • 线程的三种实现方式
  • 总结

Java内存模型

  • Java内存模型与Java内存结构不同,Java内存结构指的是jvm内存分区。Java内存模型描述的是多线程环境下原子性,可见性,有序性的规则和保障。
  • Java内存模型提供了主内存和工作内存两种抽象,主内存指的是共享区域 ,工作内存指的是线程私有工作空间。
  • 当一个线程访问共享数据时,需要先将共享数据复制一份副本到线程的工作内存(类比操作系统中的高速缓存),然后在工作内存进行操作,最后再把工作内存数据覆盖到主内存。主内存和工作内存交互通过特定指令完成。
  • 如下为并发内存模型图

多线程环境下原子性,可见性,有序性分别指的是

  • 原子性:程序执行不会受到线程上下文切换的影响。
  • 可见性:程序执行不会受到CPU缓存影响。
  • 有序性:程序执行不会受到CPU指令并行优化的影响。

主内存和工作内存的交互命令

  • lock:把主内存的一个变量标记为一个线程锁定状态。
  • unlock:把主内存中处于锁定状态的变量释放出来。
  • read:把主内存的变量读取到线程工作内存。
  • load:把工作内存的值放入工作内存变量副本中。
  • use:把工作内存变量的值传递给执行引擎。
  • assign:把执行引擎接收到的值赋值给工作内存变量。
  • store:把工作内存的值传送到主内存中。
  • write:把工作内存的值写入到工作内存变量。

内存模型的原子性

Java内存模型只保证store和write两个命令按顺序执行,但不保证连续执行,因此多个线程同时写入共享变量可能出现线程安全问题。

诸如i++的操作,首先将主存中的变量i的值拷贝一份拿到线程的本地内存,在本地内存进行自增操作,然后将新的i值写回主存。
但是涉及到多线程环境下的线程上下文切换就会出现问题,可能线程1将i值拿来进行自增操作,然后还来不及写回主存,时间片用完,轮到线程2执行,线程2对i进行自减操作,然后轮到线程1时,线程1将上一次的值写回内存,就会将线程2上一步的计算结果覆盖,就会产生错误的结果。

通过多线程的学习我们知道,对共享数据加锁可以保证操作的原子性,相当于i++操作对应底层命令是原子化绑定的,这样就不会出现线程安全问题,但是会导致程序性能降低。

内存模型的可见性

  • 对于频繁从主存取值的操作,JIT可能会将其进行优化,以后每次操作不从主存取值,而是从CPU缓存中取值。一旦线程1每次从寄存器取值,那么此时主存中变量值的变化对于线程1来说就是不可见的。
  • 如下,子线程是无法感知主存中flag的修改的,子线程就无法停止。
    public class Test {
        static boolean flag = true;
        public static void main(String[] args) throws InterruptedException {
            //3秒后线程无法停止
            new Thread(()->{
                while(flag){
                }
            }).start();
            Thread.sleep(3000);
            System.out.println("flag = false");
            flag =false;
        }
    }
    

  • 有两种方法可以保证主存中数据的可见性,方法1是加锁。加锁既可以保证原子性,又可以保证可见性。
    public class Test {
        static boolean flag = true;
        public static void main(String[] args) throws InterruptedException {
                    new Thread(()->{
                        while(flag){
                            synchronized (Test.class){}
                        }
                    }).start();
                    Thread.sleep(3000);
                    System.out.println("flag = false");
                    flag =false;
                }
    }
    

  • 还有一种方法是使用volatile关键字,它可以保证当前线程对共享变量的修改对另一个线程是一直可见的。volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但是volatile关键字只能保证可见性,不能保证原子性。volatile适用于一个线程写多个线程读的应用场景,保证各个线程可以实时感知到其他线程更新的数据。
    public class Test {
        static volatile boolean flag = true;
        public static void main(String[] args) throws InterruptedException {
                    new Thread(()->{
                        while(flag){
                        }
                    }).start();
                    Thread.sleep(3000);
                    System.out.println("flag = false");
                    flag =false;
                }
    }
    

  • 对于多线程同时操作共享变量的情况,使用volatile关键字依然会出现线程安全问题,因为原子性无法保证。
public class Test {
    static volatile int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            for(int i=0;i<100000;i++){
                a++;
            }
        }).start();
        new Thread(()->{
            for(int i=0;i<100000;i++){
                a--;
            }
        }).start();
        Thread.sleep(1000);
        System.out.println(a); //不能保证a为0
    }
}

内存模型的有序性

有序性是指在单线程环境中, 程序是按序依次执行的。而在多线程环境中, 程序的执行可能因为指令重排而出现乱序。

指令重排是指在程序执行过程中, 为了性能考虑, 编译器和CPU可能会对指令重新排序。这种排序(比如两个变量的定义顺序)不会影响单线程的结果,但是会对多线程程序产生影响。

比如 a=1 b=2两条语句就可能发生指令重排。而 a=1,b=a+1 不会发生指令重排。

示例:线程1执行f1方法,线程2执行f2方法。两个线程同时执行,可能发生如下结果: f1中发生指令重排 flag=true先执行,a=1后执行。线程1先执行flag=true,然后轮到线程2执行,此时flag为true,执行if语句,i=1。这就是指令重排造成的程序错乱。

class Test{
    int a = 0;
    boolean flag = false;
    public void f1() {
        a = 1;
        flag = true;
    }
    public void f2() {
        if (flag) {
            int i =  a +1;
        }
    }
}

可以用volatile修饰flag来禁用指令重排达到有序性。

加锁也可以避免指令重排带来的混乱,但是本身并没有禁止指令重排,因为保证了原子性,所以即使指令重排在同步代码块中依然相当于单线程执行,也不会有逻辑上的错误。

指令重排优化的底层原理

一个指令的执行被分成:取指、译码、访存、执行、写回 5个阶段。然后,多条指令可以同时存在于流水线中,同时被执行。
指令流水线并不是串行的,并不会因为一个耗时很长的指令在“执行”阶段呆很长时间,而导致后续的指令阻塞。相反,流水线是并行的,多个指令可以同时处于同一个阶段,只要CPU内部相应的处理部件未被占满即可。

比如,依次有两条指令a和b需要执行,如果是串行执行,它们的执行过程如下

指令a                          指令b
阶段1 阶段2 阶段3 阶段4 阶段5    阶段1 阶段2 阶段3 阶段4 阶段5

但是,假如阶段2耗时很长,使用串行的方式就无法在一个阶段阻塞的时候去执行其他阶段。

如下就是流水线的方式来执行,当指令a的阶段2阻塞时,完全可以去执行指令b的阶段1,这样就提高了程序执行效率,最大程度利用CPU各个部件。

指令a
阶段1 阶段2 阶段3 阶段4 阶段5
指令b
      阶段1 阶段2 阶段3 阶段4 阶段5

因此指令重排就是对于一个线程中的多个指令,可以在不影响单线程执行结果的前提下,将某些指令的各个阶段进行重排序和组合,实现指令级并行。

valatile原理

如下,假设对变量a用valatile关键字修饰。

valatile int a = 0;

那么,对变量a的写指令之后都会插入写屏障,对变量a的读指令之前都会插入读屏障。

a++;
//写屏障
//读屏障
int b = a;

写屏障会保证写屏障之前的所有对共享数据的改动都会同步到主存中。读屏障会保证读屏障之后对共享数据的读取操作都会到主存去读取。这样就保证了,每次对valatile变量的修改对其他线程始终是可见的,从而保证了可见性。

另外,写屏障会保证写屏障之前的指令不会被排到写屏障后面。读屏障会保证读屏障之后的代码不会排到读屏障前面。这样就保证了有序性。

如下,由于写屏障的存在,int b=1;语句只能排在 a++前面,不能颠倒顺序。

int b=1;
a++;
//写屏障

volatile与加锁的区别

volatile只能保证可见性和有序性,不能保证原子性,加锁既可以保证可见性 原子性 有序性都可以保证。

volatile只适用于一个线程写,多个线程读的情况,对于多个线程写的情况,必须要加锁。

加锁相对于volatile是更加重量级的操作,所以一般能用volatile解决的问题就不要加锁。

先行发生原则

先行发生是Java内存模型中定义的两项操作之间的偏序关系。如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响被操作B察觉。

先行发生原则–是判断是否存在数据竞争、线程是否安全的主要依据。先行发生原则主要用来解决可见性问题的。

如下代码

//以下操作在线程A中执行
i = 1;
//以下操作在线程B中执行
j = i;
//以下操作在线程C中执行
i = 2

如果A先行发生于B,B先行发生于C,那么必然j的值为1。如果A先行发生于B,B和C没有先行发生关系,那么j的值可能为1也可能为2。

Java内存模型存在一些天然的先行发生关系,这些先行发生关系不需要任何的同步操作,就可以保证其线程安全。

1、程序次序规则。在一个线程内,书写在前面的代码先行发生于后面的。确切地说应该是,按照程序的控制流顺序,因为存在一些分支结构。

2、Volatile变量规则。对一个volatile修饰的变量,对他的写操作先行发生于读操作。

3、线程启动规则。Thread对象的start()方法先行发生于此线程的每一个动作。

4、线程终止规则。线程的所有操作都先行发生于对此线程的终止检测。

5、线程中断规则。对线程interrupt()方法的调用先行发生于被中断线程的代码所检测到的中断事件。

6、对象终止规则。一个对象的初始化完成(构造函数之行结束)先行发生于发的finilize()方法的开始。

7、传递性。A先行发生B,B先行发生C,那么,A先行发生C。

8、管程锁定规则。一个unlock操作先行发生于后面对同一个锁的lock操作。

线程的三种实现方式

  • 使用内核线程实现
  • 内核线程就是直接由操作系统内核支持的线程,通过内核完成线程的切换。
  • 通过线程调度器来负责线程调度,即将线程任务分配到指定处理器。
  • 在用户态,每个内核级线程会一 一对应一个轻量级进程,就是通常所说的用户级线程,多个用户级线程可以组成一个用户进程。
  • 如下所示:p进程 LWP用户线程 KLT内核线程 Thread Scheduler 线程调度器

  • 由于内核线程的支持,每个用户线程都是独立调度单位,即使有一个用户线程阻塞了,也不会影响当前进程其他线程执行。但是用户线程切换 创建 终止都要内核支持,内核与用户态切换代价较高。
  • Java就是使用内核线程实现的,无论是windows还是linux都是基于内核线程实现的。
  • 使用用户线程实现
  • 操作系统内核只能感知到用户进程,用户进程为操作系统内核的基本调度单位。
  • 基于用户进程实现的用户线程,线程的创建 切换 销毁都是进程自己管理,与内核没有关系。因为操作系统只能把处理器资源分配到进程,那么线程的运行 阻塞 生命周期管理都要用户进程自己来实现。
  • 内核不参与线程调度,因此线程的上下文切换开销比较小,但是实现起来非常复杂,而且当一个用户级线程阻塞整个进程都会阻塞,并发度不高。

  • 混合模式实现
  • 用户线程和内核线程使用M对N的映射来实现,兼顾两者的优点。

总结

本篇文章就到这里了,希望能给你带来帮助,也希望您能够多多关注我们的更多内容!

(0)

相关推荐

  • 详解Java多线程与并发

    一.进程与线程 进程:是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位. 线程:是进程的一个执行路径,一个进程中至少有一个线程,进程中的多个线程共享进程的 资源. 虽然系统是把资源分给进程,但是CPU很特殊,是被分配到线程的,所以线程是CPU分配的基本单位. 二者关系: 一个进程中有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈区域. 程序计数器:是一块内存区域,用来记录线程当前要执行的指令地址 . 栈:用于存储该线程的局部变量,这些局部变量是

  • java多线程创建及线程安全详解

    什么是线程 线程被称为轻量级进程,是程序执行的最小单位,它是指在程序执行过程中,能够执行代码的一个执行单位.每个程序程序都至少有一个线程,也即是程序本身. 线程的状态 新建(New):创建后尚未启动的线程处于这种状态 运行(Runable):Runable包括了操作系统线程状态的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着CPU为它分配执行时间. 等待(Wating):处于这种状态的线程不会被分配CPU执行时间.等待状态又分为无限期等待和有限期等待,处于无

  • Java多线程之Interrupt中断线程详解

    一.测试代码 https://gitee.com/zture/spring-test/blob/master/multithreading/src/test/java/cn/diswares/blog/InterruptTests.java 二.测试 为了方便理解简介中 interrupt 的概念, 写个 DEMO 测试一下 /** * 调用 interrupt 并不会影响线程正常运行 */ @Test public void testInvokeInterrupt() throws Inter

  • 详解在Java中如何创建多线程程序

    创建多线程程序的第一种方式:创建Thread类的子类 java.lang.Thread类:是描述线程的类,我们想要实现多线程程序,就必须继承Thread类 实现步骤: 1.创建一个Thread类的子类 2.在Thread类的子类中重写Thread类中的run方法,设置线程任务(开启线程要做什么?) 3.创建Thread类的子类对象 4.调用Thread类中的方法start方法,开启新的线程,执行run方法 void start()使该线程开始执行;Java虚拟机调用该线程的run方法. 结果是两

  • Java多线程之线程状态的迁移详解

    一.六种状态 java.lang.Thread 的状态分为以下 6 种,它们以枚举的形式,封装在了Thread类内部: NEW:表示线程刚刚创建出来,还未启动 RUNNABLE:可运行状态,该状态的线程可以是ready或running,唯一的决定因素是线程调度器 BLOCKED:阻塞,线程正在等待一个monitor锁以便进入一个同步代码块 WAITING:等待,一种挂起等待的状态.一个线程处于waiting是为了等待其他线程执行某个特定的动作. TIMED_WAITING:定时等待. TERMI

  • java多线程:基础详解

    目录 Java内存模型 主内存和工作内存的交互命令 内存模型的原子性 内存模型的可见性 内存模型的有序性 指令重排优化的底层原理 valatile原理 volatile与加锁的区别 先行发生原则 线程的三种实现方式 总结 Java内存模型 Java内存模型与Java内存结构不同,Java内存结构指的是jvm内存分区.Java内存模型描述的是多线程环境下原子性,可见性,有序性的规则和保障. Java内存模型提供了主内存和工作内存两种抽象,主内存指的是共享区域 ,工作内存指的是线程私有工作空间. 当

  • Java 多线程实例详解(三)

    本文主要接着前面多线程的两篇文章总结Java多线程中的线程安全问题. 一.一个典型的Java线程安全例子 public class ThreadTest { public static void main(String[] args) { Account account = new Account("123456", 1000); DrawMoneyRunnable drawMoneyRunnable = new DrawMoneyRunnable(account, 700); Thr

  • Java 多线程实例详解(二)

    本文承接上一篇文章<Java多线程实例详解(一)>. 四.Java多线程的阻塞状态与线程控制 上文已经提到Java阻塞的几种具体类型.下面分别看下引起Java线程阻塞的主要方法. 1.join() join -- 让一个线程等待另一个线程完成才继续执行.如A线程线程执行体中调用B线程的join()方法,则A线程被阻塞,知道B线程执行完为止,A才能得以继续执行. public class ThreadTest { public static void main(String[] args) {

  • Java多线程ThreadPoolExecutor详解

    目录 1 newFixedThreadPool 2 newCachedThreadPool 3 newSingleThreadExecutor 4 提交任务 5 关闭线程池 前言: 根据ThreadPoolExecutor的构造方法,JDK提供了很多工厂方法来创建各种用途的线程池. 1 newFixedThreadPool public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolE

  • java数组基础详解

    数组 数组(Array):相同类型数据的集合. Java 数组初始化的两种方法: 静态初始化: 程序员在初始化数组时为数组每个元素赋值: 动态初始化: 数组初始化时,程序员只指定数组的长度,由系统为每个元素赋初值. 数组是否必须初始化 对于这个问题,关键在于要弄清楚数组变量和数组对象的差别.数组变量是存放在栈内存中的,数组对象是存放在堆内存中的.数组变量只是一个引用变量,他能够指向实际的数组对象. 所谓的数组初始化并非对数组变量初始化,而是对数组对象进行初始化. 定义数组 方式1(推荐,更能表明

  • java 多线程-锁详解及示例代码

    自 Java 5 开始,java.util.concurrent.locks 包中包含了一些锁的实现,因此你不用去实现自己的锁了.但是你仍然需要去了解怎样使用这些锁. 一个简单的锁 让我们从 java 中的一个同步块开始: public class Counter{ private int count = 0; public int inc(){ synchronized(this){ return ++count; } } } 可以看到在 inc()方法中有一个 synchronized(th

  • java 多线程死锁详解及简单实例

    java 多线程死锁 相信有过多线程编程经验的朋友,都吃过死锁的苦.除非你不使用多线程,否则死锁的可能性会一直存在.为什么会出现死锁呢?我想原因主要有下面几个方面: (1)个人使用锁的经验差异     (2)模块使用锁的差异     (3)版本之间的差异     (4)分支之间的差异     (5)修改代码和重构代码带来的差异 不管什么原因,死锁的危机都是存在的.那么,通常出现的死锁都有哪些呢?我们可以一个一个看过来,     (1)忘记释放锁 void data_process() { Ent

  • Java面向对象基础详解

    目录 一.前言 什么是对象? 什么是类? 类和对象的关系? 类的定义 ? 怎么创建对象? 格式: 创建对象的作用? Phone类下: PhoneDemo下: 二.封装 封装的好处? Student类下: 代码: StudentDemo类下: 代码: 总结 一.前言 我们上次学过java的方法,现在我们来学习新的一篇,也算是java中比较重要的一节了 面向对象基础是java中核心. 面向对象主要包括封装.继承.多态 我们这节主要讲的是封装,在这之前我们先来了解一下类和对象的定义和关系 什么是对象?

  • Java中CountDownLatch进行多线程同步详解及实例代码

    Java中CountDownLatch进行多线程同步详解 CountDownLatch介绍 在前面的Java学习笔记中,总结了Java中进行多线程同步的几个方法: 1.synchronized关键字进行同步. 2.Lock锁接口及其实现类ReentrantLock.ReadWriteLock锁实现同步. 3.信号量Semaphore实现同步. 其中,synchronized关键字和Lock锁解决的是多个线程对同一资源的并发访问问题.信号量Semaphore解决的是多副本资源的共享访问问题. 今天

  • Java 基础详解(泛型、集合、IO、反射)

    计划把 Java 基础的有些部分再次看一遍,巩固一下,下面以及以后就会分享自己再次学习的一点笔记!不是有关标题的所有知识点,只是自己觉得模糊的一些知识点. 1.对于泛型类而言,你若没有指明其类型,默认为Object: 2.在继承泛型类以及接口的时候可以指明泛型的类型,也可以不指明: 3.泛型也数据库中的应用: 写一个 DAO 类对数据库中的数据进行增删改查其类型声明为 <T> .每张表对应一个类,对应每一张表实现一个类继承该 DAO 类并指明 DAO 泛型为该数据表对应的类,再实现一个与该表匹

随机推荐