java多线程:基础原理

java多线程:基础原理


java支持多线程编程,为了能够深入理解java多线程机制,以及解决多线程的安全问题,本文介绍多线程的基础知识和原理分析。

Part1 概念总结

线程的概念

线程(Thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

同一进程中的多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack),自己的寄存器环境(register context),自己的线程本地存储(thread-local storage)。

 

线程vs进程:

A.多个进程的内部数据和状态都是完全独立的,(进程就是执行中的程序,程序是静态的概念,进程是动态的概念)。而多线程是共享一块内存空间和一组系统资源的,有可能互相影响(多个线程可以在一个进程中)

B.线程本身的数据通常只有寄存器数据,以及一个程序执行时使用的堆栈,所以线程的切换比进程切换的负担要小。

 

多线程编程

在单个程序中可以同时运行多个不同的线程执行不同的任务。但是具体执行哪个线程是由cpu随机决定的。

多线程编程的目的,就是“最大限度地利用CPU资源”,当某一线程的处理不需要占用CPU而只和I/O等资源打交道时,让需要占用CPU资源的其他线程有机会获得CPU资源。

Java中如果我们自己没有产生线程,系统会自动产生一个线程,该线程为主线程 ,main方法就在主线程上运行,我们的程序都是由线程来执行的。

 多任务处理被所有的现代操作系统所支持。然而,多任务处理有两种截然不同的类型:基于进程的和基于线程的。


基于进程的多任务处理

(1)基于进程的多任务处理是更熟悉的形式。

进程(process)质上是一个执行的程序。因此基于进程的务处理的特点允许你的计算机同时运行两个或更多的程序。举例来说,基于进程的多任务处理使你在运用文本编辑器的时候可以同时运行java编译器。在基于进程的多任务处理中,程序是调度程序所分派的最小代码单位。

(2)进程是重量级的任务,需要分配给他们独立的地址空间。进程间通信是昂贵和受限的。进程的转换也是很需要花费的。


VS


基于线程的多任务处理

(1)基于线程的多任务处理环境中,线程是最小的执行单位。这意味着一个程序可以同时执行两个或者多个任务的功能。例如,一个文本编辑器可以在打印的同时格式化文本。

(2)多线程程序比多进程程序需要更少的代价。多线程是轻量级的任务。它们共享相同的地址空间并且共同分享同一个进程。线程间通信是便宜的,线程间的转换也是低成本的。

(3)多线程使CPU的利用率提高


Part2 JVM的内存模型

要想对java多线程的相关问题理解透彻,那么就必须知道JVM的内存模型。
在多处理器系统中,内存是由所有CPU共同访问的,每个处理器会有自己的高效缓存,处理器可以直接从高效缓存中获得数据,而不需要直接从内存中获取数据,这样加速了对数据的访问,而且减少了内存总线的阻塞,从而提升了系统性能。
多处理器中存在的一个问题是:如果两个处理器同时处理同一块内存区域的时候会发生什么?即缓存一致性问题。解决缓存一致性问题的方法有两种:使用缓存一致性协议和在总线使用LOCK锁。
所以,在处理器层,相应的内存模型提供规范,保证由其他处理器对同一内存的写操作对当前处理器是可见的,而当前处理器对该内存的写操作对其他处理器来说也是可见的。
在强内存模型中,所有的处理器在任何时候都可以即时看到所给内存区域内数据最近变化值。
在弱内存模型中,为了看到其他处理器执行的写操作结果或者是本处理器的写操作被其他处理器可见,处理器需要执行特定的指令来刷新局部的处理器缓存区,或者是使局部处理器缓存不可见。这些特定的指令被看做是一种内存屏蔽(Memory Barrier)技术。当出现上锁和解锁操作的时候就会出现内存屏蔽。

接下来分析一下JVM中的内存模型与安全


Java作为平台无关性语言,JLS(Java语言规范)定义了一个统一的内存管理模型JMM(Java Memory Model),JMM屏蔽了底层平台内存管理细节,在多线程环境中必须解决可见性和有序性的问题。java中的线程安全问题无非是要控制多个线程对某个资源的有序访问或修改。

与现有的硬件存储模型相似,JMM规定了jvm有主内存(Main Memory)和工作内存(Working Memory),主内存存放程序中所有的类实例、静态数据等变量,是多个线程共享的,而工作内存存放的是该线程从主内存中拷贝过来的变量以及访问方法所取得的局部变量,是每个线程私有的,其他线程不能访问,每个线程对变量的操作都是以先从主内存将其拷贝到工作内存再对其进行操作的方式进行,多个线程之间不能直接互相传递数据通信,只能通过共享变量来进行。
由于多个线程并不是直接操作主存中的数据,而是操作各自的工作内存的数据,这样就导致了数据不一致性问题。


(上图给出了jvm的存储模型)

为了实现主内存和工作内存之间具体的交互协议,JVM规范定义了8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的:lock、unlock、read,load,use,assign,store,write。

lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

read(读取):作用于主内存中的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

load(载入):作用于工作内存中的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。

assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

java内存模型还规定了在执行上述8种基本操作时必须满足以下的规则:

(1)不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存中读取了但是工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。

(2)不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存,但是什么时候同步,是由实际的执行情况决定的。

(3)不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。

(4)一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说,就是对一个变量实施use、store操作之前,必须先执行过了assign和load操作。

(5)一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。

(6)如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使员工这个变量前,需要重新执行load或assign操作初始化变量的值。

(7)如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。

(8)对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。


JVM的特性:


1.共享性

在上面提到的java内存模型中,数据是存储在主内存中,各个线程可操作性的数据是共享的,但是各个线程拥有自己的工作内存,工作内存中存放的是主内存数据的拷贝。


2. 互斥性

资源互斥是指同时只允许一个访问者对其进行访问,具有唯一性和排它性。在多线程编程中,允许多个线程同时对数据进行读操作,但同一时间内只允许一个线程对数据进行写操作,所以可以将锁分为共享锁和排它锁,也叫作读锁和写锁。java中可以使用synchronized关键字来保证数据的互斥性。


3. 可见性

当线程操作某个对象时,执行顺序如下:

(1)从主存复制变量到当前工作内存(read & load)

(2)执行代码,改变共享变量值(use & assign)

(3)用工作内存数据刷新主存中的数据(store & write)

当一个共享变量在多个线程的工作内存中都有副本时,如果一个线程修改了这个共享变量,那么其他线程应该能够看到这个被修改后的值,但是主内存和工作内存的同步不是立马进行的,这样就会出现其他线程不能立马发现,共享的数据已经被修改了,这就是多线程的可见性问题。

java中可以通过synchronized或volatile关键字来保证可见性。对volatile变量操作时,如果修改了volatile变量的值,那么会立马将工作内存的值写入到主内存中,这样就保证了可见性。


4. 原子性

原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。换句话说,就是一次操作,是一个连续不可中断的过程,数据不会执行到一半的时候被其他线程所修改。

保证原子性的最简单方法是操作系统指令,就是如果一次操作对应一条操作系统指令,这样肯定可以保证原子性。但是很多操作不能通过一条指令完成:比如,对long型和double型数据的操作。还有就是i++这样的操作,实际也是分为三个步骤完成的:(1)读取整数i的值;(2)对i进行加一操作;(3)将结果写回内存。所以在多线程中会出现下面的问题:


java内存模型里直接保证的原子性变量操作包括:read、load、use、assign、store、write,在基本数据类型上执行这些操作是具备原子性的。但是对于(没有被volatile修饰的)64位的数据类型:long和double类型数据来说,虚拟机会将读写操作分为两次32位的操作。这样就不具备了原子性。所以可以使用volatile修饰变量来保证原子性。

如果应用程序需要一个更大范围的原子性保证,java内存模型还提供了显式锁和synchronized关键字。java内存模型中提供了lock和unlock操作,但是虚拟机并没有将lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和moniterexit来隐式地使用这两个操作,这两个字节码指令反映到java代码中就是synchronized修饰的代码。


5. 有序性

重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。重排序分为三类:

(1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序,这样可以尽可能减少寄存器的读取、存储次数,充分复用寄存器的存储值。

(2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。(数据依赖:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。)

(3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的

从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:(直接盗一下图)


上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对统一内存地址的多次写,可以减少对内存总线的占用。然而写缓冲区有很多好处,但是每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。


假设处理器A和处理器B按程序的顺序并行执行内存访问,最终却可能得到x = y = 0的结果。具体的原因如下图所示:


这里处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到x = y = 0的结果。

 从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序 为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。此时,处理器A的内存操作顺序被重排序了(处理器B的情况和处理器A一样, 这里就不赘述了)。

 这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操做重排序。


java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入。

happens-before

从JDK5开始,java使用新的JSR -133内存模型(本文除非特别说明,针对的都是JSR- 133内存模型)。JSR-133提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另 一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 与程序员密切相关的happens-before规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens- before于该线程中的任意后续操作。
  • 监视器锁规则:对一个监视器锁的解锁,happens- before于随后对这个监视器锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens- before于任意后续对这个volatile域的读。
  • 传递性:如果A happens- before B,且B happens- before C,那么A happens- before C

注意,两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要 求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。happens- before的定义很微妙,后文会具体说明happens-before为什么要这么定义。

 

happens-before与JMM的关系如下图所示:


如上图所示,一个happens-before规则通常对应于多个编译器重排序规则和处理器重排序规则。对于java程序员来说,happens- before规则简单易懂,它避免程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。

参考资源

以下为本文的参考资源列表,其中一些讲解很详细,很精彩,所以会有部分直接引用原文。

JVM的重排序

《深入理解java虚拟机》之内存模型与安全

深入理解Java内存模型

Java 并发编程:核心理论


  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值