计算机的运算速度与它的IO速度差距太大,大量时间都花费在磁盘I/O、网络通信或者数据库访问上,为了不让CPU在大部分时间都处于等待IO的状态,应该使用多线程充分发挥CPU的运算能力。
(1) 为了解决CPU运算速度和IO速度差别太大的问题,加入了读写速度更快的高速缓存。
(2) 由于每个处理器都有自己的高速缓存,它们共享一个主内存,为了保证数据一致性问题,就需要 一致性协议。
(3) 为了使CPU的运算单元能尽量被充分利用,CPU可能会进行代码乱序执行,来进行优化,这样可以保证计算结果不变,但是计算过程改变,但是效率被优化了。
java虚拟机规范定义了一种java内存模型(JMM),来屏蔽各种硬件和操作系统的内存访问差异,让java程序在各种平台下都能达到一致的内存访问效果。
与物理机对应,java虚拟机也有自己的内存模型,和物理机的模型很类似:
图中每个java线程都有一个自己的工作内存,工作内存通过Save和Load操作和主内存之间进行数据存取。
工作内存 在虚拟机中对应 虚拟机栈的部分区域,在物理机中对应 寄存器、高速缓存和主存的各一部分,线程对变量的所有操作(读取、赋值)都在工作内存进行,线程不能直接读写主内存中的变量,不同线程直接也不能直接相互访问变量。
主内存 在虚拟机中对应 java堆中的对象实例数据部分,在物理机中对应 物理硬件的内存RAM。
主内存与工作内存的交互有一个交互协议,java内存模型定义了8种操作来完成数据交互:
下面4个操作作用于主内存的变量:
(1) lock:把一个变量标记为一条线程独占的状态
(2) unlock:把一个处于锁定状态的变量释放出来
(3) read:把一个变量的值从主内存传输到工作内存(主内存read之后,工作内存那边必须load)
(4) write:接收来自工作内存store后的变量值(工作内存store之后,主内存才能write),即将变量写入了内存
下面4个操作作用于工作内存的变量:
(1) load:把从主内存中read到的变量值放入工作内存的变量副本中(主内存read之后,工作内存才能load)
(2) use:把工作内存中的一个变量值传递给执行引擎,如果一个字节码指令用到了一个变量,就会执行这个操作
(3) assign:把从执行引擎接收到的值赋值给工作内存中的一个变量,如果一个字节码指令是赋值,就会执行这个操作。
(4) store:把工作内存中一个变量的值传送到主内存中,等待主内存的write接收。(工作内存store之后,主内存必须write)
volatile是java虚拟机提供的最轻量级的同步机制,如果使用了volatile关键字修饰变量,当一个线程修改了变量值之后,另一个线程能马上得知。
2.4.1 volatile可以保证可见性 (volatile的第1个语义)
volatile型变量被修改后会发生:
(1) CPU立即写入数据
当一个线程修改了变量值之后,java执行引擎会执行一个操作,让CPU立即将缓存或者寄存器中的该数据写入到内存中
(2) 其他CPU更新该数据
其他处理器通过嗅探总线上传播过来了数据检查自己缓存的值是不是过期了,如果过期了,就会将对应的缓存中的数据置为无效。而当处理器对这个数据进行修改时,会重新从内存中把数据读取到缓存中进行处理。
2.4.2 volatile不能保证一致性
volatile变量的 运算 是不安全的
多线程环境下执行 i++ 的时候,即使 i 被volatile修饰也没用,因为 i++ 是复合操作 “读取–运算–赋值”,这里面涉及多个指令。
当线程1刚刚读取到 i 的时候,线程2可能在很短的时候后将 i 改变了,那么线程1读取到的 i 就过期了。尽管volatile能保证马上通知处理线程1的CPU去更新数据,缓存中的数据也确实更新了,但是 java执行引擎不知道 ,在线程2修改 i 之前,执行引擎已经将 i 之前的错误值(一个常量)放到栈顶准备计算了,下一步就是计算了,已经没有机会将后来的正确值读取入栈了,因此执行引擎返回的计算结果是错误的。
只有在下面两个情况下,使用volatile能确保线程安全:
(1) 运算结果不依赖与变量本身,或者能确保只有一个线程会修改变量的值
(2) 变量不与其他变量一起构成一个复合约束 (如:a+b>2这个条件)
如果 i 是一个boolean型数据,对它只有赋值操作,没有中间的运算操作,那么java执行引擎就能将后来更新后的正确值赋给它了。
2.4.3 禁止指令重排序 (volatile的第2个语义)
具体来说就是会在对变量的赋值操作之后加上一条指令(内存屏障),使重排序不能将后面的语句重排序到内存屏障前的位置,也就是说,在内存屏障指令执行结束的时候,能确保之前的指令是按照原有顺序执行的。内存屏障之后的指令顺序是可以打乱的。
非原子协定就是虚拟机不保证对64位数据类型的lock、unlock、read、load等操作是原子性的。因此对long和double型数据的读写不是原子性的,开发的时候最好是将long和double型数据声明为volatile。
2.5.1 原子性:
java内存模型的read、load、assign、use、store、write操作都是原子性的,因此可大致认为基本数据类型的 访问读写 都是原子性的(除了long和double),为了实现更大范围的原子性保证,java内存模型还提供了lock和unlock操作。尽管并未直接提供这两种操作给用户使用,但是提供了高层次的monitorenter和monitorexit来隐式使用,这也就是java中的synchronized关键字,它能确保同步块之间的所有操作都具有原子性。
synchronized工作原理
synchronized是通过java对象头和Monitor对象实现的,对象头是java对象的内存布局的一部分,其他两部分是实例数据和对齐填充。
对象头中的Mark Word默认是下图的情况:
默认情况下,加入synchronized之后,锁状态就变成:
synchronized使用的是monitor重量级锁,每个对象创建之后都有一个monitor对象与其关联,一旦用synchronized给一个对象上锁之后,该对象就会形成一个指针,指向它的monitor对象,这就是为什么任何java对象都可以作为锁的原因。
java6之后,jvm为了进行锁优化,加上了偏向锁、轻量级锁、自旋锁、锁消除等锁优化策略,对象加上了偏向锁和轻量级锁两种额外的锁状态。
偏向锁
偏向锁的优点是,加锁和解锁(非竞争下)基本不需要额外的消耗,适用于只有一个线程访问同步块的场景
可以通过修改JVM运行参数来开启关闭偏向锁:
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking
轻量级锁
轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁; 在满足一定条件的时候,轻量级锁会升级为重量级锁。
轻量级锁是一种多线程优化,通过 CAS 来避免进入开销较大的互斥操作。优点是竞争的线程不会阻塞,提高了程序的响应速度,缺点是如果始终得不到锁竞争的线程,使用自旋会消耗 CPU。
synchronized的执行过程:
偏向锁是在无锁争用的情况下使用的,也就是同步开在当前线程没有执行完之前,没有其它线程会执行该同步块,一旦有了第二个线程的争用,偏向锁就会升级为轻量级锁,如果轻量级锁自旋到达阈值后,没有获取到锁,就会升级为重量级锁。
以上的锁都是jvm自己实现的,是jvm自己设计的一种优化策略。但是这些锁都不是我们能控制的,但是我们可以借鉴他们的思想(偏向锁、轻量级锁、自旋锁、锁消除等技术),设计自己的锁优化策略。
2.5.2 可见性
可见性指的是,一个变量被其中一个线程修改之后,其他线程能立即发现,java中除了用volatile实现可见性之外,还可以通过synchronized和final实现可见性,final修饰(或者static final)的变量一旦构造完成,就会被其他线程看到,它们无须同步就能被其他线程正确访问。
2.5.3 有序性
有序性即程序执行的顺序按照代码的先后顺序执行。在单线程环境下,指令重排序不会造成影响,但是多线程下会出现问题(某些赋值语句可能会提前执行),java内存模型提供了volatile和synchronized来实现。
2.5.4 先行发生原则(happens before)
这个原则是判断 数据是否存在竞争、线程是否安全 的主要依据,该原则的内容是:
如果说 操作A先行发生于操作B,它等价于
在操作B发生前,操作A产生的影响能被操作B观测到,
当衡量并发安全问题的时候,不能单纯从代码执行顺序判断,而应该用先行发生原则去判断。
java中实现线程主要有3种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级进程混合实现
轻量级进程就是我们平常使用的java线程,每个轻量级进程都由一个内核线程支持,是1对1的关系。