JMM实现三大特性
实现并发编程中的三大特性,并发编程才是安全的,原子性、可见性与有序性,那么JMM模型需要补足或者使用哪些特殊机制来满足顺序一致性模型呢?
- 原子性(Atomicity),Java内存模型来直接保证的原子性变量操作包括read、load、use、assign、store和write这六个,我们可以大致的认为基本数据类型的访问读写是具备原子性的(64位的long和64位的double除外)。
- synchronized关键字,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性
- 原子类进行的CAS操作也能满足原子性。
- 可见性(Visibility),可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,有以下三种方式满足可见性:
- volatile关键字,volatile修饰的共享变量保证新值能立即同步到主内存,以及每次使用前立即从主内存刷新。
- synchronized关键字,可见性是由对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store、write操作)中这条规则获得的
- final关键字,可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值。
- 有序性(Ordering),Java程序的天然有序性:在本线程内观察,所有操作都是有序的;在一个线程中观察另外一个线程,所有操作都是无序的。(前半句是指线程内表现为串行的语义,后半句是指令重排序现象和工作内存与主内存同步延迟现象)。有以下三种满足有序性
- volatile关键字,volatile本身就包含禁止指令重排序的语义来保证有序性,使用内存屏障,即重排序的指令不能放到内存屏障之前。
- synchronized关键字,因为一个变量在同一时刻只允许一条线程对其进行lock操作。这个规则决定了持有同一个锁的两个同步块只能串行的进入,指令在临界区内可以重排,但不会影响最终执行结果
- 先行发生原则(Happens-Before),如果Java内存模型中所有的有序性都只靠volatile和synchronized来完成,那么有一些操作将会变得很繁琐,在JMM中,如果一个操作执行的结果需要对另一个操作可见,这两个操作需要有一定的执行顺序,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间
Java在底层使用volatile关键字和synchronized关键字来实现这三个特性,之后的高级并发包,也都是基于这两个关键字进行的扩展。
Java的进程与线程
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- 进程和线程的区别是什么
- Java多线程的模型是什么样的
- Javad 线程同步和调度
接下来我们看这部分的内容。
进程和线程的区别
什么是进程?进程是程序的⼀次执⾏过程,是系统运⾏程序的基本单位,因此进程是动态的。系统运行和关闭⼀个程序即是⼀个进程从创建,运⾏到消亡的过程。在 Java 中,当我们启动 main 函数时其实就是启动了⼀个 JVM 的进程,⽽ main 函数所在的线程就是这个进程中的⼀个线程,也称主线程
什么是线程?线程与进程相似,但线程是⼀个⽐进程更⼩的执⾏单位。⼀个进程在其执⾏的过程中可以产⽣多个线程。与进程不同的是同类的多个线程共享进程的堆和⽅法区资源,但每个线程有⾃⼰的程序计数器、虚拟机栈和本地⽅法栈,所以系统在产⽣⼀个线程,或是在各个线程之间作切换⼯作时,负担要⽐进程⼩得多,也正因为如此,线程也被称为轻量级进程
二者之间的区别与联系如下:
- 使用定位,进程是分配资源的基本单位,线程是独立运行和独立调度的基本单位,多进程是指操作系统能同时运行多个任务(程序)。多线程是指在同一程序中有多个顺序流在并发执行
- 地址空间和资源:进程间相互独立,同一进程的各线程间共享。某进程内的线程在其它进程不可见,每个进程都有独立的代码和数据空间(进程上下文),进程间的切换会有较大的开销,一个进程包含n个线程;线程没有独立的代码和数据空间,一个进程下的多个线程需要共享进程的资源,线程间切换开销小,比进程切换快的多
- 状态阶段:线程和进程一样分为五个阶段:创建、就绪、运行、阻塞、终止。
- 通信方式:进程间通信IPC(IPC是intent-Process Communication的缩写,含义为进程间通信或者跨进程通信),线程间可以直接读写进程数据段(如全局变量)来进行通信——需要进程同步和互斥手段的辅助,以保证数据的一致性。
对应于我们的JVM模型如下:
启动多少个Java程序,就会创建多少个JVM进程,也称之为JVM实例。而每一个JVM实例都是独立的,它们互不影响。这也是前面所说的一个程序可以被多个进程共用的情况
程序计数器为什么是私有的?
- 字节码解释器通过改变程序计数器来依次读取指令,从⽽实现代码的流程控制,如:顺序执⾏、选择、循环、异常处理。
- 在多线程的情况下,程序计数器⽤于记录当前线程执⾏的位置,从⽽当线程被切换回来的时候能够知道该线程上次运⾏到哪⼉了。需要注意的是,如果执⾏的是 native ⽅法,那么程序计数器记录的是 undefined 地址,只有执⾏的是 Java 代码时程序计数器记录的才是下⼀条指令的地址。
程序计数器私有为了保证线程切换后能恢复到正确的执⾏位置。
虚拟机栈和本地⽅法栈为什么是私有的?
- 虚拟机栈: 每个 Java ⽅法在执⾏的同时会创建⼀个栈帧⽤于存储局部变量表、操作数栈、常量池引⽤等信息。从⽅法调⽤直⾄执⾏完成的过程,就对应着⼀个栈帧在 Java 虚拟机栈中⼊栈和出栈的过程。
- 本地⽅法栈: 和虚拟机栈所发挥的作⽤⾮常相似,区别是: 虚拟机栈为虚拟机执⾏ Java ⽅法(也就是字节码)服务,⽽本地⽅法栈则为虚拟机使⽤到的 Native ⽅法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合⼆为⼀。
虚拟机栈和本地⽅法栈私有为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地⽅法栈是线程私有的。
堆和⽅法区为什么是公有的?
- 堆是所有线程共享的资源,其中堆是进程中最⼤的⼀块内存,主要⽤于存放新创建的对象 (所有对象都在这⾥分配内存)
- ⽅法区主要⽤于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
堆和⽅法区公有为了保证线程都能共享到堆中创建的对象以及方法区中的类型。
Java多线程模型
Java线程模型基于操作系统原生线程模型实现。因此操作系统支持怎么样的线程模型,很大程度上决定了Java虚拟机的线程如何映射,这一点在不同的平台上没有办法达成一致,虚拟机规范中也未限定Java线程需要使用哪种线程模型来实现。线程模型只对线程的并发规模和操作成本产生影响,对于Java程序来说,这些差异是透明的:
也就是一条
- Java线程就映射到一条轻量级进程(Light Weight Process)中,而一条轻量级线程又映射到一条内核线程(Kernel-Level Thread)。
- 我们平时所说的线程,往往就是指轻量级进程(或者通俗来说我们平时新建的java.lang.Thread就是轻量级进程实例的一个"句柄",因为一个java.lang.Thread实例会对应JVM里面的一个JavaThread实例,而JVM里面的JavaThread就应该理解为轻量级进程)。
- 我们在应用程序中创建或者操作的java.lang.Thread实例最终会映射到系统的内核线程
如果我们恶意或者实验性无限创建java.lang.Thread
实例,最终会影响系统的正常运行甚至导致系统崩溃
线程同步与调度
什么是线程同步?其核心就在于一个同。所谓“同”就是协同、协助、配合,也就是按照预定的先后顺序进行运行,即你先,我等, 你做完,我再做。
- 线程同步,就是当线程发出一个功能调用时,在没有得到结果之前,该调用就不会返回,其他线程也不能调用该方法。就一般而言,我们在说同步、异步的时候,特指那些需要其他组件来配合或者需要一定时间来完成的任务。
- 在多线程编程里面,一些较为敏感的数据时不允许被多个线程同时访问的,使用线程同步技术,确保数据在任何时刻最多只有一个线程访问,保证数据和操作的完整性。
线程调度方式包括两种,协同式线程调度和抢占式线程调度
线程调度方式 | 描述 | 优势 | 劣势 |
协同式线程调度 | 线程的执行时间由线程本身控制,执行完毕后主动通知操作系统切换到另一个线程上 | 某个线程如果不让出CPU执行时间可能会导致整个系统崩溃 | 实现简单,没有线程同步的问题 |
抢占式线程调度 | 每个线程由操作系统来分配执行时间,线程的切换不由线程自身决定 | 实现相对复杂,操作系统需要控制线程同步和切换 | 不会出现一个线程阻塞导致系统崩溃的问题 |
Java线程最终会映射为系统内核原生线程,所以Java线程调度最终取决于系操作系统,而目前主流的操作系统内核线程调度基本都是使用抢占式线程调度。
线程生命周期及状态切换
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- 线程的生命周期分为几个阶段,如何切换
- 切换过程中涉及的方法比较sleep、wait以及yield
接下来我们看这部分的内容。
线程生命周期及切换方法
Java线程的状态可以从java.lang.Thread
的内部枚举类java.lang.Thread$State
得知:
public enum State { NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED; }
整个状态如图所示图片来源
想要实现多线程,必须在主线程中创建新的线程对象。Java 语言使用 Thread 类及其子类的对象来表示线程,在它的一个完整的生命周期中通常要经历如下的五种状态
- 新建(NEW): 当一个Thread类或其子类的对象被声明并创建时,新生的线程对象处于新建状态
- 可运行状态(RUNABLE): RUNNABLE状态可以认为包含两个子状态:READY和RUNNING,
- 就绪(READY): 处于新建状态的线程被start()后,将进入线程队列等待CPU时间片,此时它已具备了运行的条件,只是没分配到CPU资源 可能有CPU时间片
- 运行(RUNNING): 当就绪的线程被调度并获得CPU资源时便进入运行状态 有CPU时间片
- 阻塞(BLOCKED锁阻塞): 当一个线程试图获取一个对象锁来访问资源而该对象锁正被别的线程持有时,则该线程进入BLOCKED状态,直到该线程持有对象锁,该线程转为RUNABLE状态, 无CPU时间片
- 无限期等待(WAITING): 当一个线程在等待另一个线程执行一个动作(唤醒)时,该线程处于WAITING状态,该线程不能自动唤醒,必须等待其它线程显式执行唤醒方法notify、notifyAll等
- 有限期等待(TIMED_WAITING):无需等待被显式唤醒,到达设置期限后线程会被JVM自动唤醒
- 终结(TERMINATED): 线程完成了它的全部工作run方法正常结束或线程被提前强制性地中止或出现异常导致结束
其实状态划分有很多种,这里我们就按照Java源代码的枚举状态来判定吧。六种状态的切换状态图如下图所示:
如下的执行流程更加直观一些:
方法比较
包括sleep和yield的区别以及sleep和wait的区别
sleep和yield的区别
yield方法对应了如下操作:先检测当前是否有相同优先级的线程处于同可运行状态,如有,则把 CPU 的占有权交给此线程,否则,继续运行原来的线程。所以yield方法称为“退让”,它把运行机会让给了同等优先级的其他线程,sleep则直接中断当前线程一段时间。
- sleep使线程进入Timed_Waiting状态,yield的线程依然是Runable,sleep使当前线程进入停滞状态,所以执行sleep的线程在指定的时间内肯定不会被执行;yield只是使当前线程重新回到可执行状态,所以执行yield的线程有可能在进入到可执行状态后马上又被执行
- sleep时间可设定,yield不可以,sleep 方法使当前运行中的线程睡眼一段时间,进入不可运行状态,这段时间的长短是由程序设定的,yield 方法使当前线程让出 CPU 占有权,但让出的时间是不可设定的。
- sleep不需要考虑线程优先级,yield需要,sleep 方法允许较低优先级的线程获得运行机会,但 yield方法执行时,当前线程仍处在可运行状态,所以,不可能让出较低优先级的线程些时获得 CPU 占有权
在一个运行系统中,如果较高优先级的线程没有调用 sleep 方法,又没有受到 I\O 阻塞,那么,较低优先级线程只能等待所有较高优先级的线程运行结束,才有机会运行。
sleep和wait的区别
两者的相同点是:
- 都可以使线程切换到TIMED_WAITING状态,它们都是在多线程的环境下,都可以在程序的调用处阻塞指定的毫秒数释放CPU控制权,并返回
- 都可以通过interrupt()方法打断线程的暂停状态 ,从而使线程立刻抛出InterruptedException,捕获并安全结束线程
两者的区别是:
- wait方法必须放在同步块里执行,也就是必须有同步方法修饰
- wait通常被⽤于线程间交互/通信(BLOCKED状态),sleep 通常被⽤于暂停执⾏。
- sleep方法没有释放锁,而wait方法释放了锁,使得其他线程可以使用同步控制块或者方法
- wait⽅法被调⽤后,线程不会⾃动苏醒,需要别的线程调⽤同⼀个对象上的 notify或者notifyAll⽅法,也就是锁的状态切换。
以上就是二者的异同
Java线程安全与同步方案
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- 什么是线程安全,如何保证线程安全
- Synchronized和ReentrantLock区别
- 从功能层面上看锁的分类有哪些
- 锁的优化措施有哪些,锁粗化,锁消除,锁升级
- 线程本地存储(Thread Local Storage)的实现原理是什么,ThreadLocal的内存泄露问题
接下来我们看这部分的内容。
线程安全的概念和方案
当多个线程访问一个对象时如果不考虑这些线程在执行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的,但大多数对象都不是线程安全的,我们最终的目的就是使线程安全,其实从并发的角度来讲,按照线程安全的三种策略:
- 第一个部分,阻塞(互斥)同步,我们所讨论的锁也集中在这个部分。
- 第二个部分,非阻塞同步,这个部分也就一种通过CAS进行原子类操作,其实也就是不加锁或者代码实现一些自旋锁。
- 第三个部分,无同步方案,包括可重入代码和线程本地存储(ThreadLocal)
我们使用最多的应该就是虚拟机提供的互斥同步和锁机制,互斥同步是常见的一种并发正确性保障手段。同步是指在多线程并发访问共享数据时,保证共享数据在同一时刻只能被一个线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。互斥是因,同步是果;互斥是方法,同步是目的
锁的实现方式
我们所说的锁的分类其实应该按照锁的特性和设计来划分,其实我们真正用到的锁也就那么两三种,只不过依据设计方案和性质对其进行了大量的划分。一类是原生语义上的实现
- Synchronized,它是一个:非公平,悲观,独享,互斥,可重入的重量级锁
还有一类是在JUC包下,是API层面上的实现
- ReentrantLock,它是一个:默认非公平但可实现公平的,悲观,独享,互斥,可重入的重量级锁。
- ReentrantReadWriteLocK,它是一个,默认非公平但可实现公平的,悲观,写独享/读共享,读写,可重入的重量级锁
那么我们来详细了解下这几种实现方式。
Synchronized
synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。
- 如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;
- 如果synchronized修饰的是实例方法,去取对应的对象实例
- 如果synchronized修饰的是类方法,去取对应的Class对象来作为锁对象
那么Synchronized实现的锁有什么优缺点呢?
Synchronized优点
在虚拟机规范对monitorenter和monitorexit的行为描述中,有两点是需要特别注意的。
- 首先,synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。 可重⼊锁概念是:⾃⼰可以再次获取⾃⼰的内部锁。⽐如⼀个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重⼊的话,就会造成死锁。同⼀个线程每次获取锁,锁的计数器都⾃增1,所以要等到锁的计数器下降为0时才能释放锁
- 其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入
也就是Synchronized能保证同步块内的内容在多线程下准确执行
Synchronized缺点与优化
其缺点也比较明显,Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。所以synchronized是Java语言中一个重量级的操作,优化方式就是在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态之中,也就是在直接用重量级锁之前,先让轻量级锁自旋等待下。
ReentrantLock
除了synchronized之外,我们还可以使用java.util.concurrent(下文称JUC)包中的重入锁ReentrantLock
来实现同步,ReentrantLock与synchronized很相似,他们都具备一样的线程重入特性,只是代码写法上有点区别,
- 一个表现为API层面的互斥锁(lock和unlock方法配合try/finally语句块来完成)
- 一个表现为原生语法层面的互斥锁。
相比synchronized,ReentrantLock增加了一些高级功能,主要有以下3项:定时锁等候/等待可中断、可实现公平锁,以及锁可以绑定多个条件
定时锁等候/等待可中断
ReentrantLock获取锁定有四种方式:
- lock(), 如果获取了锁立即返回,如果别的线程持有锁,当前线程则一直处于休眠状态,直到获取锁
- tryLock(), 如果获取了锁立即返回true,如果别的线程正持有锁,立即返回false
- tryLock(long timeout,TimeUnit unit), 如果获取了锁定立即返回true,如果别的线程正持有锁,会等待参数给定的时间,在等待的过程中,如果获取了锁定,就返回true,如果等待超时,返回false;定时锁等候
- lockInterruptibly():如果获取了锁定立即返回,如果没有获取锁定,当前线程处于休眠状态,直到获取锁定,或者当前线程被别的线程中断,中断锁等候
可中断特性对处理执行时间非常长的同步块很有帮助,举例说明,线程A和B都要获取对象O的锁定,假设A获取了对象O锁,B将等待A释放对O的锁定
- 如果使用 synchronized ,如果A不释放,B将一直等下去,不能被中断
- 如果 使用ReentrantLock,如果A不释放,可以使B在等待了足够长的时间以后,中断等待,而干别的事情
所以这个特性还是很重要的。
可实现公平锁
公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized
中的锁是非公平的,ReentrantLock
默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁
锁绑定多个条件
锁绑定多个条件是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait
和notify
或notifyAll
方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock
则无须这样做,只需要多次调用newCondition
方法即可。
Synchronized和ReentrantLock区别
Synchronized和ReentrantLock有什么区别和联系呢?,可以总结为以下几点:
- 两者默认都是非公平,悲观,独享,互斥,可重入锁
- synchronized 依赖于 JVM ⽽ ReentrantLock 依赖于 API,不但可以通过一些监控工具监控synchronized的锁定,而且在代码执行时出现异常,JVM会自动释放锁定,但是使用Lock则不行,lock是通过代码实现的,要保证锁定一定会被释放,就必须将unLock放到finally{}中
- ReentrantLock 拥有Synchronized相同的并发性和内存语义,此外还多了 可实现选择性通知(锁可以绑定多个条件),定时锁等候/等待可中断,可实现公平锁
需要注意,随着Synchronized的优化,性能已不能作为二者比较的标准。
锁的分类
从功能的角度出发,锁可以按照如下几个维度分类:
锁的优化措施
锁的状态变化分为两种,锁的消除、锁的粗化、内存级别的锁升级以及分段锁的实现。