synchronized的出现打破了volatile关键字的局限性(无法保证原子性和只能修饰单一变量),它可以用来锁住代码块、实例对象、类对象。
synchronized的使用
a. 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁
//Thread类
public class MyThread implements Runnable{
private static int count;
public MyThread(){
count = 0;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "准备开始执行!");
synchronized (this) {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + ": " + (count++));
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
//Main函数
public class Main {
public static void main(String[] args){
MyThread myThread = new MyThread();
Thread t1 = new Thread(myThread, "thread1");
Thread t2 = new Thread(myThread, "thread2");
t1.start();
t2.start();
}
}
执行结果:
thread1准备开始执行!
thread1: 0
thread2准备开始执行!
thread1: 1
thread1: 2
thread1: 3
thread1: 4
thread2: 5
thread2: 6
thread2: 7
thread2: 8
thread2: 9
从结果可以看出,在run方法中,thread2的自增输出在thread1的自增输出后,而没有被修饰到的print语句则可以先执行。说明在synchronized修饰代码块时获得这个对象的锁且只锁住这段代码块,不影响其他未被修饰部分的执行。
b.修饰实例方法,作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
将synchronized用来修饰run方法。
public synchronized void run() {
System.out.println(Thread.currentThread().getName() + "准备开始执行!");
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + ": " + (count++));
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
再执行,得到的结果如下:
thread1准备开始执行!
thread1: 0
thread1: 1
thread1: 2
thread1: 3
thread1: 4
thread2准备开始执行!
thread2: 5
thread2: 6
thread2: 7
thread2: 8
thread2: 9
从结果可以看出,方法被thread1抢占后,整个方法被锁住,thread2要等待thread1执行退出后才能执行。
但如果将传入两个不同实体,让其在两个线程跑,即修改main函数为:
MyThread thread1 = new MyThread();
MyThread thread2 = new MyThread();
Thread t1 = new Thread(thread1, "thread1");
Thread t2 = new Thread(thread2, "thread2");
t1.start();
t2.start();
再执行,得到结果:
thread1准备开始执行!
thread1: 0
thread2准备开始执行!
thread2: 1
thread1: 2
thread2: 3
thread1: 4
thread2: 5
thread2: 6
thread1: 6
thread1: 7
thread2: 7
由此可见,synchronized在修饰实例方法是,只是对实例对象进行了加锁,不影响到其他实例对象锁的获取。
c. 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
再接着将MyThread类的count自增封装为一个静态函数,并用synchronized修饰。即改为:
private static synchronized void count(){
System.out.println(Thread.currentThread().getName() + "准备开始执行!");
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + ": " + (count++));
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
@Override
public void run() {
count();
}
再运行得到结果:
thread1准备开始执行!
thread1: 0
thread1: 1
thread1: 2
thread1: 3
thread1: 4
thread2准备开始执行!
thread2: 5
thread2: 6
thread2: 7
thread2: 8
thread2: 9
可以看出,synchronized修饰静态方法时,类的对象实例调用函数时是对类进行加锁的,所以类的其他对象实例被挂起等待。
d. synchronized和volatile的区别
(1)volatile只能作用于变量,使用范围较小。synchronized可以用在方法、类、同步代码块等,使用范围比较广。 (要说明的是,java里不能直接使用synchronized声明一个变量,而是使用synchronized去修饰一个代码块或一个方法或类。)
(2)volatile只能保证可见性和有序性,不能保证原子性。而可见性、有序性、原子性synchronized都可以保证。
(3)volatile不会造成线程阻塞。synchronized可能会造成线程阻塞。
synchronized修饰的部分被一个线程上锁(重量锁)的后,其他线程如果来访问这部分代码就会被阻塞,直到解锁后才会被唤醒执行。而java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。为了减少获得锁和释放锁所带来的性能消耗,提高性能,synchronized在JDK 1.6以后做了优化,引入了新的锁机制。
四种锁态
锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。
a.存储方式
锁的状态保存在对象的头文件中,以32位的JDK为例:
位数 锁状态 | 25bit | 4bit | 1bit | 2bit | |
23bit | 2bit | 是否偏向锁 | 锁标志位 | ||
无锁 | 对象的hashcode | 分代年龄 | 0 | 01 | |
偏向锁 | 线程ID | 偏向时间戳 | 分代年龄 | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 | |||
重量级锁 | 指向互斥量的指针 | 10 | |||
GC标记 | 空 | 11 |
b.偏向锁
偏向锁目的是为了减少数据在无竞争情况下的性能消耗。其核心思想就是锁会偏向第一个获取它的线程,在接下来的执行过程中该锁没有其他的线程获取,则持有偏向锁的线程永远不需要再进行同步。
- 获取:当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里储存锁偏向的线程ID。以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要检查当前Mark Word中储存的线程是否指向当前线程,如果成功,表示已经获得对象锁;如果检测失败,则需要再测试一下Mark Word中偏向锁的标志是否已经被置为1(表示当前锁是偏向锁):如果没有则使用CAS操作竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
- 释放:偏向锁使用一种等待竞争出现才释放锁的机制,所以当有其他线程尝试获得锁时,才会释放锁。偏向锁的撤销,需要等到安全点。它首先会暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果不处于活动状态,则将对象头设置为无锁状态;如果依然活动,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向其他线程,要么恢复到无锁或者标记对象不合适作为偏向锁(膨胀为轻量级锁),最后唤醒暂停的线程。
c.轻量锁
轻量锁本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
- 获取:线程在执行同步块之前,JVM会现在当前线程的栈帧中创建用于储存锁记录的空间(LockRecord),并将对象头的Mark Word信息复制到锁记录中。然后线程尝试使用CAS将对象头的MarkWord替换为指向锁记录的指针。如果成功,当前线程获得锁,并且对象的锁标志位转变为“00”,如果失败,表示其他线程竞争锁,当前线程便会尝试自旋获取锁。如果有两条以上的线程竞争同一个锁,那么轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态变为“10”,MarkWord中储存的就是指向重量级锁(互斥量)的指针,后面等待的线程也要进入阻塞状态。
- 释放:轻量级锁解锁时,同样通过CAS操作将对象头换回来。如果成功,则表示没有竞争发生。如果失败,说明有其他线程尝试过获取该锁,锁同样会膨胀为重量级锁。在释放锁的同时,唤醒被挂起的线程。
d.状态转换图
锁对比
锁 | 优点 | 缺点 | 适用场景 |
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 | 适用于只有一个线程访问同步块场景。 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度。 | 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 | 追求响应时间。 同步块执行速度非常快。 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU。 | 线程阻塞,响应时间缓慢。 | 追求吞吐量。 同步块执行速度较长。 |
其他优化
- 适应性自旋:当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
- 锁粗化:锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。
- 锁消除:锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。