1

Java内存模型和线程

什么是内存模型

现代计算机的运算速度同存储,IO之间的速度存在巨大差异,为了弥补这个差异,让CPU尽可能的执行更多次的运算,压榨出更多的运算性能,于是有了让计算机同时处理多项任务的手段。但是为了匹配上CPU的运算速度与IO之间的速度差异,现代计算机引入了缓存的概念,即加入一层与CPU的运算速度相近的高速缓存,将运算所需要的数据从低速缓存或者内存甚至磁盘中复制到高速缓存中,使得CPU可以尽快的开始计算,当运算结束之后,再将结果从缓存同步回内存中。

虽然引入缓存,解决了CPU与IO之间的速度差异,但是也带来了缓存一致性问题,CPU的每个处理器都有自己的高速缓存,在对内存中的同一块数据区域存在并发操作的时候,我们需要让各个缓存器遵从一些协议,保证各个缓存器拿到的数据,都是相同且正确的。

缓存一致性原则:再多处理器系统中,每一个处理器都有自己的高速缓存,这些高速缓存共享同一主内存,当多个处理器涉及到的共享主内存为同一区域的时候,将可能导致各自的高速缓存区域中的数据不一致问题,所以需要一些操作协议来决定在这种情况下,按照什么规则将主内存的数据更新,而在特定操作协议下,对特定内存或者高速缓存进行读写的访问过程抽象就是“内存模型”。
图片描述

Java内存模型

Java内存模型又叫JMM(Java Memory Model),主要定义了程序中各个变量的访问规则,更具体的说是在JVM中将变量(实例字段,静态字段,和构成数组对象的元素,不包括局部变量和方法参数)存储到内存和从内存中取出变量的底层细节。

Java内存模型包括两个部分:主内存(所有的变量都存储在主内存中)和工作内存(每一个线程都有一个工作内存,保存该线程使用到的变量的主内存副本),线程对变量的操作只能在工作内存中进行,线程间变量的传递也必须通过主内存完成,无法直接再线程间进行直接变量传递。
图片描述

volatile修饰符:

可以认为这是JVM提供的最轻量级的同步机制,一个volatile修饰的变量具有两种特性:该变量对所有线程可见,即如果一个线程修改了该变量的值,其它线程可以立即得知这个变化。对于普通变量而言,线程A在工作内存中修改了某一变量的值之后,会写会主内存,线程B只有在这个A的写回任务完成之后,去从主内存读取这个变量,新修改的值才会对线程B可见。

volatile实现原理:

JVM解决缓存不一致问题的方式是,当对用volatile修饰的变量进行写操作的时候,JVM向CPU发送一条Lock指令,锁定主内存的变量,标识这个变量为某一个工作线程独有,然后CPU将工作内存中的数据写回到主内存,同时会将其它工作内存中的该数据置为失效,这样当其他线程从它的工作内存中读取该变量的时候,将会强制性的从主内存中进行读取。

但这并不意味着,volatile修饰的变量是并发安全的,即我们不能简单的认为,对volatile变量的操作在并发条件下一定最终可以得到正确结果,原因是在于:Java里的运算操作,比如一个自增运算,会对应到三条字节码指令:取常量1,add运算,写回。更严谨的原因是,即便某个运算编译成字节码指令对应到的是原子指令,它也可能需要若干条机器码指令实现。

那么volatile的适用场景就是:

1、运算结果不依赖当前变量的值,或者可以确保,任何时刻,只有单一线程会对该变量进行修改操作。

2、变量不需要与其它状态变量共同参与不变约束,就是说,volatile变量可以单独确定一个状态,比如我们可以用volatile变量来控制并发。

volatile变量的第二层含义就是禁止指令重排,指令重排指的就是再现代微处理器上,为了提高机器的运行效率,会采取将指令乱序执行的方式,在条件允许的情况直接执行当前有能力执行的后续指令,避开因为等待顺序指令所需要的时间。对于普通变量而言,只要能够保证指令重排后最终的计算结果与指令顺序执行的结果保持一致,重排操作就是正确的(例如a--和a++,我们假设认为这两条是原子操作,他们谁先执行和谁后执行,最终得到的a的值都是一样的)。但是有些情况是不允许指令重排的:

1.写一个变量,读一个变量:如果读重排到写之前,那么就错误。

2.写一个变量,再写一个变量:a=1;a=2如果或者先执行,最终a=1,错误

3.读一个变量,再写一个变量:a=b;b=1,假设b先前为2,这两条指令重排的会导致最红a=1,错误。

Java内存模型中大体上对于基本数据类型的访问读写是具备原子性的(long和double例外),但是对于比如引用类型,JMM提供了lock和unlock操作(汇编级别)满足对非基本类型的原子操作,这两个操作再更高层次(字节码指令级别)提供了monitorenter和monitorexit指令来隐式实现(monitor又叫管程,两个字节码指令需要控制一个对象,这个对象叫锁,在两个指令之间的字节码指令,不会出现两个线程可以同时执行其间的情况),这两条指令在代码中的体现就是同步代码块了(synchronized修饰的代码块)。

Java与线程:

线程实现方式

线程的实现有三种方式:

1.使用内核线程:直接借助操作系统的内核支持的线程,线程之间的调度由内核通过操纵调度器完成,每一个内核线程可以视为内核的一个分身。也可以用轻量级进程实现,轻量级进程就是我们通常说的线程,一个轻量级进程都由一个内核线程支持,即OS必须先支持内核线程,然后才能有轻量级进程。但是因为基于内核线程,所以这种方式实现的线程操作,都需要通过系统调用来完成,而系统调用的代价比较高,而且需要消耗一定的内存资源,所以一个OS所支持的轻量级进程的数量是有限的。

2.使用用户线程:一个线程如果不是内核线程,就可以认为是用户线程,在这个角度上看,轻量级进程也是用户线程的一种,狭义上说,用户线程就是完全建立在用户空间的线程库上的线程,用户线程的管理调度等完全由在用户态中完成,不需要经过内核,因此相对于使用内核线程实现,用户线程消耗的资源更少,也可以支持更大规模的线程数量。

3.用户线程+轻量级进程混合实现:该方法综合轻量级进程和用户线程两种方式的优点:轻量级进程作为用户线程和内核线程之间的桥梁,系统调用通过轻量级进程完成。

Java线程的实现再JDK1.2之前是基于用户线程实现,再JDK1.2及以后,被替换为OS原生线程模型实现。而从这里,也体现了Java的平台无关特性其实是因为不同平台的JVM实现虽然不同,但是这些不同的实现向上提供的确实相同的API。

线程调度方式

线程调度方式分为两种:

1.协同式线程调度:线程的执行时间由线程自身控制,当一个线程完成自己的任务之后,主动通知系统切换到另一个线程,优点是实现简单,缺点是如果一个线程出现问题,可能程序会一直阻塞。

2.抢占式线程调度:线程的执行事件由系统分配,无法自身决定自己的执行时间,切换也不由自身决定。因为控制权交给了系统,所以不会出现一个线程阻塞导致整个进程阻塞的问题,Java的线程调度基于抢占式线程调度实现。

线程的状态和状态转换:

线程的五种状态:

1.新建:new,创建了线程但是没有启动它。

2.运行:runnable:该状态的线程可能是正在运行running,也可能是正在等待CPU为其分配计算时间ready。

3.1.无限期等待:waiting:这种状态下的线程,CPU不会为其主动分配时间,需要等到被其它线程显式唤醒,让一个线程进入无限期等待的方式是:不设置时间参数的Object.wait(),不设置时间参数的Thread.join(),LockSupport.park(),

3.2.期限等待:timed waitting:情形同上,只是可以设定时间参数,由系统在一定时间后唤醒它们,Thread.sleep(),Object.wait,Thread.join,LockSupport.parkNanos/parkUntil

4.阻塞:阻塞态与等待态的差异在于,阻塞态在等待一个获取一个锁(排它锁),当另一个线程放弃这个锁,然后该线程获取到这个锁之后,CPU将为其分配执行时间,而等待状态则是,线程等待一定时间或者等待被其它线程唤醒。当程序进入同步区域(同步代码块)的时候,将进入阻塞状态(如果它没有获取到这个锁的话)。

5.结束:Terminated:线程执行完毕,是已经终止线程的状态。

这五种状态的转换,如下图:
图片描述
上面涉及到的几个方法的作用和差异:
start方法是开启一个线程的方法,run方法是线程中的具体任务,如果直接调用run方法,等同于直接调用线程对象的一般方法。

对于sleep()方法,我们首先要知道该方法是属于Thread类中的。而wait()方法,则是属于Object类中的。sleep()方法导致了程序暂停执行指定的时间,让出cpu该其他线程,但是他的监控状态依然保持者,当指定的时间到了又会自动恢复运行状态;在调用sleep()方法的过程中,线程不会释放对象锁。而当调用wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备。

notify方法只唤醒一个等待(对象的)线程并使该线程开始执行。所以如果有多个线程等待一个对象,这个方法只会唤醒其中一个线程,选择哪个线程取决于操作系统对多线程管理的实现。notifyAll 会唤醒所有等待(对象的)线程,但是具体哪一个线程将会第一个处理仍然取决于操作系统的实现(即便设置了优先级,也只是对虚拟机的一个建议,并不以定完全按照这个建议来从优先级高的开始处理)。如果当前情况下有多个线程需要被唤醒,推荐使用notifyAll 方法。


一天八升水
4 声望1 粉丝

计算机本科在读