高速缓存的概念:高速缓存是为了解决内存和处理机之间的问题,作为一个缓冲——将处理机运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存中,这样处理期就无须等待缓慢的内存读写了。
引入高速缓存带来的问题:缓存一致性,再多处理机系统中,每一个处理机都有自己的高速缓存,但是他们又共享同一主内存。所以当多个处理机的运算任务都涉及同一主内存区域时,将可能导致各自的数据不一样。为了解决这一问题又引入了缓存一致性协议。
内存模型:内存模型可以理解为再特定的操作协定下,对特定的内存或高速缓存进行读写访问的过程抽象。
处理机、高速缓存、主内存之间的关系:
Java内存模型
java虚拟机规范中视图定义一种java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序再各种平台下都能达到一致的内存访问效果。
主内存与工作内存
java内存模型的主要目标:定义程序各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。这里所说的java与java编程中的变量略有区别,它包括实例变量、静态字段和构成数组对象的元素,不包括局部变量和方法参数(线程私有)。为了获得较好的执行效率,java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交换,也没有限制即时编译器调整代码执行顺序这类权力。
Java内存模型规定所有变量都存储在主存中。每条线程还有自己的工作内存,线程的工作内存保存了被线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主存来完成。
线程、主内存、工作内存之间的关系:
内存间的交互操作
主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、从工作内存同步回主内存之类的实现细节,java内存模型中定义了8中操作来完成:
- Lock(锁定):作用于主内存的变量,将主内存该变量标记成当前线程私有的,其他线程无法访问它把一个变量表示为一条线程独占的状态。
- Unlock(解锁):作用于主内存变量把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存中的变量副本中。
- Use(使用):作用于工作内存中的变量,把工作内存中一个变量的值传递给执行引擎,每当迅即遇到一个需要使用到的变量的值的字节码指令时执行这个操作。
- Assign(赋值):作用域工作内存的变量,他把一个从执行官引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store (存储):作用域工作内存的变量,它把工作内存中的一个变量的值传送到主内存中,以便随后的write操作使用。
- write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
如果把一个变量从主内存复制到工作内存,按顺序执行read和load操作;
如果把变量从工作内存同步回主内存,按顺序执行store和write操作。java内存模型还规定在执行上述8中基本操作必须满足如下规则:
不允许read和load、store和write操作之一单独出现,即不允许一个变量从主存读取了但工作内存不接受,或者从工作内存发起回写了但主存不接受的情况。
不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
不允许一个线程无原因的把数据从线程的工作内存同步回主内存中。
一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化的变量。
一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数得unlock,变量才会被解锁。
如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load和assign操作初始化变量得值
如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。
对一个变量执行unlock之前,必须先把此变量同步回主内存中。
对于volatile型变量的特殊规则
关键字volatile可以说是java虚拟机最轻量级的同步机制。
当一个变量被volatile修时候,他将具备以下两种属性:
- 第一是保证对所有线程的可见性,“可见性”指当一条线程改变了这个变量的值,新值对于其他线程来说是可以立即得知的。
- 第二个是禁止指令重排序优化
volatile变量在各个线程中的工作内存中不存在一致性问题(在各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在不一致的问题),但是java里面的运算并非是源自操作,导致volatirl变量的运算在并发下一样是不安全的。
volatile运算:
public class M{
public static volatile int race = 0;
private static final int THREADS_COUNT = 20;
public static void add(){
race++;
}
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for(int i = 0; i < THREADS_COUNT; i++){
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for(int i = 0; i < 10000; i++){
add();
}
}
});
threads[i].start();
}
//等待所有累加线程都结束
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(race);
//如果代码正确并发,输出结果为200000。但是每次运行都不会得到期望的结果。
}
}
上面代码的结果每次运行都不一样的,而且总是小于200000,说明并不可以顺利的并发。
由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过枷锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程改变变量的值
- 变量不需要与其他的状态变量共同参与不变约束
第二个特性使用volatile修饰是禁止指令重排序优化,普通变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这点,这也就是Java内存模型中描述的所谓的“线程内表现为串行的语义”
在某些情况下,volatile的同步机制的性能确实要由于锁。尽管大多数情况下volatile的总开销仍然要比锁低,我们在volatile与锁之中悬着的唯一依据仅仅是volatile的语义能否满足使用场景的需求。
对long和double型变量的特殊规则
允许虚拟机将没有被volatile修饰的64位数据类型(long和double)的读取操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这4个操作的原子性,就点就是long和double的非原子协定(Nonatomic Treatment of double and long Variables)。
如果多个线程共享一个为声明为volatile的long或double类型变量,并同时对他们进行读取和修改操作,那么有些线程可能会读取到一个即非原值,也不是其他线程修改值得代表了“半个变量”的数值。
不过这种读取到“半个变量”的情况非常罕见(在目前商用虚拟机中不会出现),因为Java内存模型虽然允许虚拟机不把long和double变量的读写实现成原子操作,但允许虚拟机选择把这些操作实现为具有原子性的操作,而且还“强烈建议”虚拟机这样实现。
原子性、可见性与有序性
原子性:由java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write,我们可以大致认为进本数据类型的访问具有原子性(long和double除外)。
可见性:只当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
除了volatile,java还有两个关键字可以实现可见性,synchronized和final 。同步块的可见性是由“对一个变量执行unlock操作之前,必须把此变量同步回主内存中(执行store和write操作)”,这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this 引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问“初始化了一半”的对象),那么其他线程中就能看见final字段的值。
有序性:java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工作内训与著内训同步延迟的”现象。
java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”,这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行的进入。
先行先发生原则
先行先发生原则是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以通过几条规则一揽子地解决并发环境下两个操作之间是否可能存在冲突的问题。
先行先发生是java内存模型中定义地两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存变量的值、发送了消息、调用了方法等。
天然的先行先发生关系
- 程序次序规则:在一个线程中,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
- 管程锁定规则:一个unlock操作先行先发生于后面对同一个锁的lock操作。这里必须强调的是同一锁,而“后面”是指时间上的先后顺序。
- volatile变量规则:对一个volatile变量的写操作先行先发生于后面对这个变量的读操作,这里的“后面”是指时间上的先后顺序。
- 线程启动规则:Thread对象的start()方法先行先发生于此线程的每一个动作。
- 线程终止规则:线程中的所有操作都先行发生于此线程的终止检测,我们可以通过Thread.join()方法结束,Thread。isAlive()的返回值等手段检测到线程已经终止执行。
- 线程终端规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断时间的发生,可以通过Thread.interrupt()方法检测到是否有中断发生。
- 对象终结原则:一个对象的初始化完成(构造函数执行结束)先行先发生于它的finalize()方法的开始。
- 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。
时间先后顺序与先行先发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行先发生原则为准。
java与线程
并发不一定依赖多线程,但是在java中并发大多数过户都与线程脱不了关系。
线程的实现
我们知道线程是更轻量级的调度执行单位。现成的引入,可以把一个进程的资源分派和执行调度分开,各个线程既可以共享进程资源,又可以独立调度(线程是CPU调度的基本单位)。
实现线程的三种方式:使用内核线程实现,使用用户线程实现和使用用户线程加轻量级进程混合实现。
1.使用内核线程实现
内核线程(KLT)就是直接由操作系统内核支持的线程。这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为一个内核的分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核叫做多线程内核。
程序不会直接使用内核线程,而是使用内核线程的接口——轻量级进程(LWP),轻量级进程就是我们所指的线程。由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能由轻量级进程。这种轻量级进程与内核之间1:1的关系称为一对一线程模型。如图:
由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使有一个轻量级进程在系统调用中赌赛了,也不会影响着整个进程继续工作。局限性**:系统调用的代价相对较高,需要在用户态和内核态中来回调用。会消耗一定的内核资源,因为每个轻量级进程都需要一个内核线程的支持。**
使用用户线程实现
用户线程(UT)的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。程序使用得当,这种线程完全不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以是支持规模更大的线程量。这种进程与用户线程之间为一对多的关系。
优势:不需要系统内核支援,劣势也在于没有系统内核的支持,所有的线程操作都需要用户程序自己处理。
使用用户线程加轻量级线程混合实现
这种用户线程与轻量级线程之间的数量比是不定的,为N:M。如图:
使用用户线程来进行擦创建,切换,析构等操作,而且还可以支持大规模的用户线程并发。使用轻量级线程来实现用户与内核线程之间的桥梁。
Java线程调度
- 协同式调度:如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另一个线程上.协同式多线程的最大好处是实现简单,所有没有什么线程安全的同步问题.它的坏处是,线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行切换,那么程序就会一直阻塞在那里。
- 抢占式调度:如果采用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,现成的切换不由线程本身来实现.在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java使用的线程调度方式就是抢占式调度。
状态转换
-
新建:创建后尚未启动的线程处于这种状态
-
运行:Runnable包括了操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着CPU为它分配执行时间。
-
无限期等待:处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显式地唤醒.以下方法会让线程陷入无限期的等待状态
- 没有设置Timeout参数的Object.wait()方法
- 没有设置Timeout参数的Thread.join()方法
- LockSupport.park()方法
-
限期等待:处于这种状态的线程也不会被分配CPU执行时间,不过无须等待其他线程显式地唤醒,在一定时间之后它们还由系统自动唤醒.以下方法会让线程进入无限期等待状态:
- Thread.sleep()方法
- 设置了Timeout参数的Object.wait()方法
- 设置了Timeout参数的Thread.join()方法
- LockSupport.parkNanos()方法
- LockSupport.parkUnit()方法
-
阻塞:线程被阻塞了,"阻塞状态"与"等待状态"的区别是:"阻塞状态"在等待着获取一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而"等待状态"则是在等待一段时间,或者唤醒动作的发生.在程序等待进入同步区域的时候,线程将进入这种状态。
-
结束(Terminated):已终止线程的线程状态,线程已经结束执行。