type
status
date
slug
summary
tags
category
icon
password

📡 Java线程间通信


在并发编程中,多个线程之间采取什么机制进行通信(信息交换),什么机制保证数据操作的同步?
在Java语言中,采用的是共享内存模型来实现多线程之间的信息交换和数据同步的。
线程之间通过共享程序公共的状态,通过读-写内存中公共状态的方式来进行隐式的通信。同步指的是程序在控制多个线程之间执行程序的相对顺序的机制,在共享内存模型中,同步是显式的,程序员必须显式指定某个方法/代码块需要在多线程之间互斥执行。

🦾 Java内存结构


在说Java内存模型之前,我们先说一下Java的内存结构,也就是运行时的数据区域:
Java虚拟机在执行Java程序的过程中,会把它管理的内存划分为几个不同的数据区域,这些区域都有各自的用途、创建时间、销毁时间。这些区域里的一些数据在JVM启动的时候创建,在JVM退出的时候销毁,而其他的数据依赖于每一个线程,在线程创建时创建,在线程退出时销毁。
notion image

Java运行时数据区分为下面几个内存区域:


1. 程序计数器 The PC Register

严格来说是一个数据结构,用于保存当前正在执行的程序的内存地址,由于Java是支持多线程执行的,所以程序执行的轨迹不可能一直都是线性执行。当有多个线程交叉执行时,被中断的线程的程序当前执行到哪条内存地址必然要保存下来,以便用于被中断的线程恢复执行时再按照被中断时的指令地址继续执行下去。为了线程切换后能恢复到正确的执行位置, 每个线程都需要有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储,我们称这类内存区域为 “线程私有”的内存,这在某种程度上有点类似于 “ThreadLocal”,是线程安全的。

2. 栈 Java Virtual Machine Stacks

Java栈总是与线程关联在一起的,每当创建一个线程,JVM就会为该线程创建对应的Java栈,在这个Java栈中又会包含多个 栈帧(Stack Frame),这些栈帧是与每个方法关联起来的,每运行一个方法就创建一个栈帧,每个栈帧会含有一些 局部变量操作栈方法返回值等信息。每当一个方法执行完成时,该栈帧就会弹出栈帧的元素作为这个方法的返回值,并且清除这个栈帧,Java栈的栈顶的栈帧就是当前正在执行的活动栈,也就是当前正在执行的方法,PC寄存器也会指向该地址。只有这个活动的栈帧的本地变量可以被操作栈使用,当在这个栈帧中调用另外一个方法时,与之对应的一个新的栈帧被创建,这个新创建的栈帧被放到Java栈的栈顶,变为当前的活动栈。同样现在只有这个栈的本地变量才能被使用,当这个栈帧中所有指令都完成时,这个栈帧被移除Java栈,刚才的那个栈帧变为活动栈帧,前面栈帧的返回值变为这个栈帧的操作栈的一个操作数。
由于 Java栈是与线程对应起来的,Java栈数据不是线程共有的,所以不需要关心其数据一致性,也不会存在同步锁的问题。
在Java虚拟机规范中,对这个区域规定了两种异常状况:
  1. 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;
  1. 如果虚拟机可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。在Hot Spot虚拟机中,可以使用-Xss参数来设置栈的大小。栈的大小直接决定了函数调用的可达深度。
notion image

3. 堆 Heap

堆是JVM所管理的内存中国最大的一块,是被 所有Java线程锁共享的,不是线程安全的,在JVM启动时创建。堆是存储Java对象的地方,这一点Java虚拟机规范中描述是:所有的对象实例以及数组都要在堆上分配。Java堆是GC管理的主要区域,从内存回收的角度来看,由于现在GC基本都采用分代收集算法,所以Java堆还可以细分为: 新生代老年代新生代再细致一点有 Eden空间From Survivor空间To Survivor空间等。

4. 方法区 Method Area

方法区存放了要加载的 类的信息(名称、修饰符等)、 类中的静态常量类中定义为final类型的常量类中的Field信息类中的方法信息,当在程序中通过Class对象的getName.isInterface等方法来获取信息时,这些数据都来源于方法区。方法区是被Java线程锁共享的,不像Java堆中其他部分一样会频繁被GC回收,它存储的信息相对比较稳定,在一定条件下会被GC,当方法区要使用的内存超过其允许的大小时,会抛出OutOfMemory的错误信息。方法区也是堆中的一部分,就是我们通常所说的Java堆中的永久区 Permanet Generation,大小可以通过参数来设置,可以通过-XX:PermSize指定初始值,-XX:MaxPermSize指定最大值。

5. 常量池 Runtime Constant Pool

常量池本身是 方法区中的一个数据结构。常量池中存储了如 字符串、 final变量值类名和方法名常量。常量池在编译期间就被确定,并保存在已编译的.class文件中。
一般分为两类:字面量和引用量。
字面量就是字符串、final变量等。
类名和方法名属于引用量。引用量最常见的是在调用方法的时候,根据方法名找到方法的引用,并以此定为到函数体进行函数代码的执行。引用量包含:类和接口的权限定名、字段的名称和描述符,方法的名称和描述符。

6. 本地方法栈 Native Method Stacks

本地方法栈和Java栈所发挥的作用非常相似,区别不过是Java栈为JVM执行Java方法服务,而本地方法栈为JVM执行Native方法服务。本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。
Java运行时内存区域与硬件内存架构之间的桥接 硬件内存架构没有区分线程栈和堆,对于硬件,所有的线程栈和堆都分布在主内中。部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。
如下图所示:
notion image

🗯️ JAVA内存模型(JMM)


运行时涉及到两种内存, 主内存工作区内存,其中工作区内存通常为CPU的高速缓存区用来加快内存数据读取操作的(各线程独立).所有的变量内容都存在主内存中,当需要对内存数据进行操作时,数据将会从主存中load到工作区缓存并由CPU计算和赋值操作,然后再由工作区内存write到主存中,读取时如果工作区内存中已经有(loaded)则直接使用;工作区内存保存了线程使用的变量的副本,线程不可以直接操作主内存,只能操作工作区内存,对于需要变更的变量,需要通过一系列回写指令集同步到主内存中.且工作区内存是线程独占的,主内存是线程共享的。
如下为操作集:
  1. lock: 对主内存中的变量"加锁",标记为一个线程持有.如果一个变量已经被lock,其他线程尝试lock将被阻塞,同一个线程可以多次lock,不过锁的引用次数将会+1,需要unlock同样次数,才能解锁.对一个变量lock将会导致清空工作区内存中此变量的副本,即当其他线程再次使用此变量时需要重新获取.
  1. unlock: 对主内存中的变量"释放锁",释放锁的线程和持有锁的线程必须是同一个线程,无法对没有加锁的变量执行unlock.
  1. read: 由工作区内存向主内存发出"read"操作,随后必须执行load操作.
  1. load: 工作区内存中加载"read"操作指定的变量,并放入副本中.此指令需要和read保持顺序
  1. use: 变量交付给执行引擎做计算,当JVM需要使用变量时,将会使用此操作.
  1. assign: 在工作区内存中,将变量更新为某个值.一个被assign操作的变量,必须被write到主内存,如果没有被assign的变量不能被write到主内存.
  1. store: 工作区内存向主内存发出"同步"操作.
  1. write: 工作区内存将store操作指定的变量同步到主内存中.此操作需要和store保持顺序.
其中 read -> load , store -> write 指令必须按照顺序执行,即不能load一个没有被read操作指定的变量,也不能write一个没有被store操作指定的变量,不过这 read + load/store + write 不一定必须是连续的,其中间仍然可以有其他指令.(volatile有特例)
volatile是java提供的轻量级变量同步机制,它确保了变量可见性,即任何一个线程修改了volatile修饰的变量,其他线程将立即可见.对于普通变量因为存在主内存和工作区内存的复制和同步,所以无法具备此特性.volatile变量存储在主内中,任何线程需要使用此变量时,必须再次read,因为其他线程对此变量的更改,将会被其他线程在使用此变量时立即获得新值.
volatile只是保证了 可见性,但是它并非线程安全,因为如果线程一旦read到此值然后进行计算,在尚未write到主内存时其他线程也做了同样的操作,那么volatile变量的最终结果将无法达到预期。如果期望volatile变量线程安全,必须同步或者 CAS.volatile 变量操作时, read -> load -> use 三个操作是连续的, assign -> store -> write 三个操作是连续的.
通常 volatile 用在“值”类型上,引用类型也有用到,如 FilterInputStream 里的 volatile InputStream involatile修饰的变量如果是对象或数组之类的,其含义是对象或数组的地址具有可见性,但是对象或数组内部的成员改变不具有可见性。

🦺 线程安全


线程是执行任务的 最小调度单元, 内核线程是OS创建和管理的线程,它将有内核完成线程的切换以及调度(CPU调度). 任何一个java线程都对应一个内核线程, 即java线程的所有特性都基于内核并受制于内核.在linux和windows系统中,一个java线程就是底层的一个内核线程.java对线程的调度基于内核,在主流的系统中,广泛采用了"抢占式"调度机制,即线程都以"争抢CPU资源"的姿态来运行,最终被运行的线程将有内核的调度算法来决定,如果线程没有获得运行资源,那么线程将被"暂停".."协同式"调度已经不适合多线程(进程)的系统,它表现为线程之间互相"谦让",如果一个线程获得运行资源,那么它将一直运行下去直到结束,如果一个线程是"长时间"的,那么极有可能这个线程将独占一个CPU,而其他线程无法获得资源..

线程状态: (see Thread.State)


  1. NEW: 新创建线程,尚未开启.
  1. RUNNABLE: 当前线程已经被启动或者正在被运行,处于此状态的线程标明即将或者已经得到了运行资源.
  1. WAITING: 如果线程因为 wait()/join()/LockSupport.park(this)/sleep()导致当前线程无法继续执行或者获得资源.
  1. BLOCKED: 如果当前线程因为对象锁获取时,被"阻塞",那么线程将处于 BLOCKED状态,此状态下,线程不会释放资源.
  1. TERMINATED: 线程执行结束,资源即将被回收.
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; }
在JAVA中(甚至任何语言或者平台中)确保 线程安全的方式,无外乎 "同步锁""CAS"
1. "同步锁"是一种粗暴而严格的同步手段,它强制对资源的访问必须队列化,一个资源在任何时候只能有一个线程可访问.在java中 "synchronized"修饰词可以用来同步方法的调用, synchronized可以指定需要同步的对象,如果没有指定,默认为当前对象, 如果是static方法,则表示对Class同步. synchronized 关键词在编译之后,最终会生成2个指令 : monitorentermonitorexit,执行引擎如果遇到 monitorenter指令,将会尝试获取对象锁,如果获取成功,则锁计数器+1 ,同时工作区中的对象值将视为无效,重新从主存中load; monitorexit将导致锁计数器-1,即释放锁,此时将会把对象值从工作区缓存中write到主存中; 如果计数器为0,则表示此对象没有被任何线程加锁.如果获取锁失败,当前线程阻塞.此外 synchronized本身具有"重入性"语义,如果此对象上的monitor是当前线程,那么锁获取操作将直接成功.
我们不再争论 synchronized锁ReentrantLock API锁谁更优秀,这一把双刃剑,性能方面两者在普通情况下(即无复杂递深的lock调用或者多层synchronized)性能几乎差不多, synchronized稍微优秀一些.但是 ReentrantLock提供了多样化的控制以及 Condition机制,可以帮助我们有效的控制并发环境中,让线程遵循条件的阻塞和唤醒;例如 BlockingQueue的实现机制.
2. CAS(Compare and swap),设计方式上更像一种 "乐观锁",通过 "比较" + "更新"这种无阻塞的手段实现数据在多线程下的"安全性". 在JAVA中CAS操作遍布 Atomic包下的API中,底层使用一个闭源的 Unsafe.compareAndSwapInt(Object,valueOffset,expect,update),其中需要告知对象的内存地址.CAS会出现一个有趣的问题,就是 ABA,即A变量被更改为B之后,再次被更改为A,此时对于持有A数据的线程尝试更改值是可以成功了,就像B值从来就没有出现过一样。其实吧,这个问题不是问题,既然有线程把数据更改为A, 那么后续的线程操作就应该遵守现在的结果,而无需关注过去的过程.

并发编程中的重要概念


Java线程之间的通信由 Java内存模型(JMM,JAVA Memory Model)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory,或叫工作区内存),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java内存模型是围绕着并发编程中 可见性原子性有序性这三个特征来建立的。

1. 可见性

也就说假设一个对象中有一个变量i,那么i是保存在 main memory中的,当某一个线程要操作i的时候,首先需要从 main memory中将i 加载到这个线程的 working memory中,这个时候 working memory中就有了一个i的拷贝,这个时候此线程对i的修改都在其 working memory中,直到其将i从 working memory写回到 main memory中,新的i的值才能被其他线程所读取。从某个意义上说,可见性保证了各个线程的 working memory的数据的一致性。
可见性遵循下面一些规则:
  1. 当一个线程第一次读取某个变量的时候,会从 main memory中读取最新的;
  1. 当一个线程运行结束的时候,所有写的变量都会被 flushmain memory中;
  1. 当一个线程释放锁后,所有的变量的变化都会 flushmain memory中,然后一个使用了这个相同的同步锁的进程,将会重新加载所有的使用到的变量,这样就保证了可见性; volatile的变量会被立刻写到 main memory中,读则重新加载。
  1. final修饰的变量,在构造函数数一旦初始化完成,并且在构造函数中并没有把 “this”的引用传递出去( “this”引用逃逸是很危险的,其他的线程很可能通过该引用访问到只 “初始化一半” 的对象),那么其他线程就可以看到 final变量的值。

2. 原子性

还拿上面的例子来说,原子性就是当某一个线程修改i的值的时候,从取出i到将新的i的值写给i之间不能有其他线程对i进行任何操作。也就是说保证某个线程对i的操作是原子性的,这样就可以避免数据 脏读。通过 锁机制或者 CAS(Compare And Set 需要硬件CPU的支持)操作可以保证操作的原子性。
基本类型数据的访问大都是原子操作,long 和double类型的变量是64位,但是在32位JVM中,32位的JVM会将64位数据的读写操作分为2次32位的读写操作来进行,这就导致了long、double类型的变量在32位虚拟机中是非原子操作,数据有可能会被破坏,也就意味着多个线程在并发访问的时候是线程非安全的。

3. 有序性

假设在 main memory中存在两个变量i和j,初始值都为0,在某个线程A的代码中依次对i和j进行自增操作(i,j的操作不相互依赖), i++; j++; 由于,所以i,j修改操作的顺序可能会被重新排序。那么修改后的ij写到 main memory中的时候,顺序可能就不是按照i,j的顺序了,这就是所谓的 reordering,在单线程的情况下,当线程A运行结束的后i,j的值都加1了,在线程自己看来就好像是线程按照代码的顺序进行了运行(这些操作都是基于 as-if-serial语义的),即使在实际运行过程中,i,j的自增可能被重新排序了,当然计算机也不能帮你乱排序,存在上下逻辑关联的运行顺序肯定还是不会变的。但是在多线程环境下,问题就不一样了,比如另一个线程B的代码如下 if (j==1) { System.out.println(i); } 。按照我们的思维方式,当j为1的时候那么i肯定也是1,因为代码中i在j之前就自增了,但实际的情况有可能当j为1的时候i还是为0。这就是 reordering产生的不好的后果,所以我们在某些时候为了避免这样的问题需要一些必要的策略,以保证多个线程一起工作的时候也存在一定的次序。JMM提供了 happens-before 的排序策略。
对于一个线程的代码而言,我们总是以为代码的执行是从前往后的,依次执行的。这么说不能说完全不对,在单线程程序里,确实会这样执行;但是在多线程并发时,程序的执行就有可能出现乱序。

用一句话可以总结为: 在本线程内观察,操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。
前半句是指 “线程内表现为串行语义(WithIn Thread As-if-Serial Semantics)”,后半句是指 “指令重排”现象和 “工作内存和主内存同步延迟”现象。

Java提供了两个关键字 volatilesynchronized来保证多线程之间操作的 有序性。
volatile关键字本身通过加入 内存屏障来禁止指令的重排序。
synchronized关键字通过一个变量在同一时间只允许有一个线程对其进行加锁的规则来实现。

happens-before原则

Java内存模型中定义的两项操作之间的次序关系,如果说操作A先行发生于操作B,操作A产生的影响能被操作B观察到, “影响”包含了修改了内存中共享变量的值、发送了消息、调用了方法等。
下面是Java内存模型下一些”天然的“happens-before关系,这些happens-before关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们进行随意地重排序。
  1. 程序次序规则(Pragram Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环结构。
  1. 管程锁定规则(Monitor Lock Rule):一个 unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而”后面“是指时间上的先后顺序。
  1. volatile变量规则(Volatile Variable Rule):对一个 volatile变量的写操作先行发生于后面对这个变量的读取操作,这里的”后面“同样指时间上的先后顺序。
  1. 线程启动规则(Thread Start Rule)Thread对象的 start()方法先行发生于此线程的每一个动作。
  1. 线程终于规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join()方法结束, Thread.isAlive()的返回值等作段检测到线程已经终止执行。
  1. 线程中断规则(Thread Interruption Rule):对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted()方法检测是否有中断发生。
  1. 对象终结规则(Finalizer Rule):一个对象初始化完成(构造方法执行完成)先行发生于它的finalize()方法的开始。
  1. 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
一个操作”时间上的先发生“不代表这个操作会是”先行发生“,那如果一个操作”先行发生“是否就能推导出这个操作必定是”时间上的先发生 “呢?也是不成立的,一个典型的例子就是指令重排序。所以时间上的先后顺序与happens-before原则之间基本没有什么关系,所以衡量并发安全问题一切必须以happens-before 原则为准。

⁉️ Q&A


Q: synchronized 可以保证代码块中所有被访问的变量将会从主存中读入,当线程退出同步代码块时,所有被更新的变量都会被刷新回主存中去,不管这个变量是否被声明为volatile。那么java中的Lock系列是如何实现lock的时候所有被访问的变量是最新值,以及unlock的时候所有被更新的变量都刷新回主存中去的?
A: 释放锁的最后写volatile变量state,在获取锁时首先读这个volatile变量。JSR133之后增强了volatile语义,禁止了volatile变量与普通变量之间的重排序,根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变的对获取锁的线程可见。
JSR133:
加强了volatile变量的语义,需要有acquire和release语义。在原始的规范中,volatile变量的访问和非volatile变量的访问之间可以自由地重序。
加强了final字段的语义,无需显式地同步,不可变对象也是线程安全的。这可能需要在给final字段赋值的那些构造器的末尾加上store-store屏障。

🔗 引用文章


 
 
💡
有关于博客的任何问题,请在下方留言,感谢~ 🤞🏻
Linux进阶几个生活能用到的github分享

  • Giscus
  • Cusdis
Sheamus
Sheamus
I'm a geeker!
公告
type
status
date
slug
summary
tags
category
icon
password

📢📢📢 重磅更新 📢📢📢

首页添加了 ChatGPT 的入口,只要添加上你自己的 OpenAI API key 你就能轻松玩转 ChatGPT 了~~~
使用过程有任何问题请到留言区留言,感谢 👏🏻👏🏻👏🏻