参看:
volatile详解
锁的四种状态与锁升级
Volatile
Java内存模型
java内存模型规定所有变量都存在主存当中,每个线程又都有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。
并发编程的三个概念:
- 原子性:一个操作要么全部执行并且执行的过程中不会被任何因素打断,要么就都不执行。
- 可见性:当多个线程指向同一变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改的值。
- 有序性:即程序执行的顺序按照代码的先后顺序执行。JVM真正执行代码时会发生指令重排,即处理器为了提高程序运行效率,可能对输入的代码进行优化,它不保证程序中各个语句的执行顺序同代码中的执行顺序一致,但会保证程序最终执行结果和代码顺序执行的结果是一致的。其原理是:重排序时会考虑指令之间的依赖性,如果一个指令会依赖另一个指令的结果,那么另一个指令会先执行。
Volatile的作用
一旦一个共享变量被 volatile 修饰后,就具备了两层语义:
- 保证了不同线程对这个数据进行操作时的可见性
- 禁止指令重排
能保证可见性
举例:下面一段代码
//线程1
boolean stop=false;
while(!stop){
doSomething();
}
//线程2
stop=true;
每个线程在运行时都有自己的工作内存,那么线程1在运行的时候,会将stop的值拷贝一份放在自己的工作内存当中。如果当线程2更改了stop变量的值以后,还没来的及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
但是用了volatile修饰后就不一样了:
- 会强制将修改的值立即写入主存
- 当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效
- 由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。读到的就是最新的值
不能完全保证原子性
在java中,只有数字赋值操作能保证原子性,自增操作不保证原子性。使用了Valitile以后,例如x++,其实分为x+1和x=x+1的操作,如果线程1增加了x的值,但由于被阻塞没来得及写入主存中,尽管线程2是从主存中读取的,仍然读到的是原来的值,在经历两次增加后实际只增加了1。
一定程度上保证有序性
Volatile禁止指令重排的两层意思:
- 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
- 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
举例:
//x、y为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
- 由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
- 并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。
锁
锁的状态总共有四种,级别由低到高依次为:无锁、偏向锁、轻量级锁、重量级锁。四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级
锁状态
锁状态 | 存储内容 | 标志位 |
---|---|---|
无锁 | 对象的hashcode、对象分代年龄、是否为偏向锁(0) | 01 |
偏向锁 | 偏向线程ID、偏向时间戳、对象分带年龄、是否是偏向锁(1) | 01 |
轻量级锁 | 记录栈中锁记录的指针 | 00 |
重量级锁 | 指向互斥量的指针 | 11 |
锁对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗CPU | 追求响应速度,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较慢 |
无锁
无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。
偏向锁
- 字面意思是“偏向于第一个获得它的线程”的锁。
- 当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。
- 当一个线程获取锁时,会在Mark Word里存储偏向线程的ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。
- 关于锁的撤销,需要等待某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。
轻量级锁
- 轻量级锁的获取主要由两种情况:① 当关闭偏向锁功能时;② 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。
- 一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。
- 在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。
- 长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。
- 一般对象都默认开启偏向锁,它在应用程序启动几秒后才激活
Synchronized
忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁。
java中的每一个对象都可以作为锁,这是synchronized实现同步的基础:
- 普通同步方法,锁是当前实例对象
- 静态同步方法,锁是当前类的class对象
- 同步方法块,锁是括号里面的对象
sychronized是通过对象内部的一个叫做监视器锁来实现的。但是监视器锁本质又是依赖于底层操作系统的Mutex Lock来实现的。每一次挂起线程,唤醒线程都要进行一个操作系统的映射。而操作系统和实现线程之间的切换就需要从用户态转换到核心态,频繁出现程序运行转换的切换,线程的挂起和唤醒,这样会消耗大量资源,程序运行的效率低下。这种依赖于Mutex Lock的锁称为“重量级锁”。
java1.6之前monitor对象都是操作系统的,java1.6之后jvm提供了偏向锁和轻量级锁
wait()
相当于lock中的await()
notifyAll()
相当于lock中的signalAll()
Lock
java1.5引入了ReentrantLock类
使用ReentrantLock保护代码块的基本结构如下:
Lock myLock=new ReentrantLock();//构造一个用来保护临界区的可重用锁
myLock.lock();
try{
...
}finally{
myLock.unlock();
}
如果进入临界区后发现条件不符合,无法继续下去,为了保证其他线程仍能进入,需要调用await()方法,使该线程进入该条件的等待集。当锁可用时,该线程并不能马上解除阻塞,需要等待另一个线程调用signalAll()方法,这一调用将解除因为这一条件而等待的所有线程的阻塞(并不会立即激活)。当这些线程从等待集移出后,调度器将再次激活它们,它们将试图重新进入该对象。一旦锁成为可用的,它们中的某个将从await的调用中返回,获得该锁并从被阻塞的地方继续执行。
signal()是随机解除等待集中某个线程的阻塞状态。
与Synchronized的区别
- Synchronized是基于JVM层面实现的,而Lock是基于JDK层面实现的。
- Lock等待可中断,即当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。而使用Synchronized,如果一个线程不释放,别的线程将一直等待,进入阻塞状态。
- Lock和Synchronized默认都是非公平锁,但ReentrantLock可以通过带布尔值的构造函数要求使用公平锁。公平锁:即多个线程在等待同一个锁时,必须按照申请的时间顺序来依次获得锁。
ReentrantLock和Synchronized都是可重入锁,即当一个线程已经获得某个锁,可以再次获取锁而不会出现死锁。
死锁
死锁产生的四个条件:
互斥条件:资源不能共享,只能由一个进程使用
请求与保持条件:已经得到资源的进程可以再次申请新的资源
非剥夺桥段:已经分配的资源不能从相应的进程中被强制剥夺
循环等待条件:系统中若干进程组成环路,该环路中每个进程都在等待相邻进程正占用的资源
手写一个死锁:
public class Dead_Lock1 {
public static String obj1 = "obj1";
public static String obj2 = "obj2";
public static void main(String[] args) {
Thread thread1 = new Thread(new runnable1());
Thread thread2 = new Thread(new runnable2());
thread1.start();
thread2.start();
}
}
class runnable1 implements Runnable {
public void run() {
try {
System.out.println("runnable1 running.");
while (true) {
synchronized (Dead_Lock1.obj1) {
System.out.println("runnable1 lock obj1");
Thread.sleep(1000);//睡一秒以后线程2已经拿到了obj2的锁
synchronized (Dead_Lock1.obj2) {
System.out.println("runnable1 lock obj2");
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
class runnable2 implements Runnable {
public void run() {
try {
System.out.println("runnable2 running.");
while (true) {
synchronized (Dead_Lock1.obj2) {
System.out.println("runnable2 lock obj2");
Thread.sleep(1000);
synchronized (Dead_Lock1.obj1) {
System.out.println("runnable2 lock obj2");
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}