前言:如何处理共享数据的安全问题?
让每一个线程依次的去读取这个共享数据,这样就不会有任何的数据安全问题了,因为每次每个线程所操作的都是最新的数据,不会出现脏读的现象。synchronized关键字就是使每个线程依次排队操作共享变量,也就是用来处理共享数据的安全性问题。不过这种同步机制的效率很低。
一、使用范围
在Java代码中,synchronized关键字可以用在代码块和方法中:
方法:
1.实例方法:被锁的是该类的实例对象
public synchronized void method() {}
2.静态方法:被锁的是类对象
public static synchronized void method() {}
代码块:
1.实例对象:被锁的是类的实例对象
synchronized (this) {}
2.class对象:被锁的是类对象
synchronized (Synchroinzed.class) {}
3.任意实例对象Object :被锁的是配置的实例对象
String lock = "1111";
synchronized (lock) {}
如果锁的是类对象的话,不管new多少个实例对象,他们都会被锁住,即线程之间保证同步关系。其实无论对一个对象进行加锁还是对一个方法进行加锁,实际上都是对对象进行加锁。被加了锁的对象就叫锁对象,在Java中任何一个对象都能成为锁对象。
二、synchronized的执行原理
我们给代码块添加了synchronized关键字,查看字节码文件,发现执行同步代码快就要先执行monitorenter指令,退出的时候执行monitorexit指令。
public class SynchronizedDemo {
public static void main(String[] args) {
synchronized (SynchronizedDemo.class) {}
method();
}
private static void method() {}
}
查看class字节码:
任意一个对象都拥有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取该对象的监视器才能进入同步块和同步方法。如果没有获取到监视器的线程将会被阻塞在同步块和同步方法的入口处,进入到BLOCKED(阻塞)状态。
这样我们可以得到:任意线程对一对象的访问,首先要获得该对象的监视器,如果获取失败,该线程状态变为BLOCKED,进入阻塞队列,当该对象的监视器占有者释放后,在阻塞队列中的线程就会有机会重新获取该监视器。
为什么只有一次monitorenter呢?
答:因为synchronized具有重入性,即同一个锁程中,线程不需要再次获取同一把锁。
三、synchronized与JMM中的三大特性
synchronized具有原子性,可见性,有序性,具体内容可以查看这篇文章。
四、synchronized的优化
前面说了synchronized是保证同一时刻只有一个线程能够获得对象的监视器(monitor),从而进入到同步代码快或者同步方法中,即表现为互斥性。那这种方式的效率肯定低下,每次只能过一个线程。我们得整点优化来缩短获取锁的时间,这样就算挨着执行,但是每次执行的速度很快。
先说说锁的四种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。 这几个状态会随着竞争情况而逐渐升级,锁可以升级但不能降级。这种升级不降级的策略目的是为了提高获得锁和释放锁的效率。
1.偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。使用偏向锁就是减少无竞争且只有一个线程使用锁的情况下使用轻量级锁产生的性能消耗。
当一个线程访问同步块并获取锁时,会在对象头和自己的栈帧中的锁记录里存储偏向线程ID,以后该线程在进入和退出同步块时就不需要进行CAS操作来加锁和解锁了,只需要测试一下对象头的Mark Word中是否存储指向当前线程的偏向锁。
如果测试成功就表示线程已经获得了锁。如果测试失败则还需要测试一下偏向锁的标识是否为1(标识当前是偏向锁):如果没有获得偏向锁,使用CAS竞争锁;如果获得了偏向锁,使用CAS将对象头的偏向锁指向当前线程。这也就是说偏向锁只有在初始化的时候进行一次CAS操作即可。
偏向锁使用了一种等到竞争才释放锁的机制,即当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。可如果有两个线程进行竞争的时候,偏向锁就失效了升级称为轻量级锁了。这种升级称为锁膨胀。
偏向锁的撤销:需要等待全局安全点(此时没有正在执行的字节码),暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否还活着,如果没活着,则将对象头设置为无锁。如果线程活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向其他线程,要么恢复到无锁状态或者标记该对象不适合作为偏向锁,最后唤醒暂停的线程。
如果某些同步代码块大多数情况下都是由两个或以上的线程竞争的话,偏向锁就是个累赘了,对于这种情况,我们一开始关闭即可。
通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false
,那么程序默认会进入轻量级锁状态。
2.轻量级锁
轻量级锁是一种非阻塞同步的乐观锁,因为这个过程并没有挂起阻塞线程,而是让线程空循环等待,串行执行。
轻量级锁由偏向锁膨胀而来:
- 线程在自己的栈帧中创建锁记录LockRecord(开辟位置)
- 将锁对象的对象头的MarkWord复制到线程刚刚创建的锁记录中(复制锁信息)
- 将锁记录中的Owner指针指向锁对象(让线程指向锁)
- 将锁对象的对象头的MarkWord替换为指向锁记录的指针(让锁指向线程)
- 锁对象对象头的Mark Word的锁标志位变成00,即表示轻量级锁
一般轻量级锁有自旋锁和自适应自旋锁两种:
1.自旋锁
如果线程1持有锁,而线程2来竞争的时候,线程2会在原地自旋,而不是阻塞。也就是说获得锁的线程1释放锁,那这个线程2立马就能获得锁。线程在原地循环等待是会消耗cpu的,就相当于执行一个啥都没有的for循环。实验表明,大部分的同步代码快执行时间都很短,所以才有了自旋锁。
根据以上不难得出自旋锁的问题:
- 如果同步代码快执行的很慢,需要消耗大量的时间,此时在原地自旋等待的其他线程就十分耗cpu。
- 本来把一个线程的锁释放后,当前线程是能够获得锁的,可如果好几个线程都在竞争,这就会导致一些线程获取不到锁,还在原地循环等待消耗cpu,甚至一直获取不到锁。
基于问题2,我们可以给线程空循环设置一个次数,如果线程循环超过这个次数的话使用自旋锁就不合适了,此时进行锁膨胀,将锁升级为重量级锁。默认情况是10,可以通过-XX:PreBlockSpin
修改。
2.自适应自旋锁
自旋锁的线程空循环等待的自旋次数并非固定的,而是动态的根据实际情况来改变,这就是自适应自旋锁。即线程1刚获得了一个锁,当它释放锁的后,线程2获得锁。在线程2运行的过程中,线程1又想获得锁了,不过线程2没有释放锁,线程1就自旋等待。JVM认为,由于线程1刚刚获得过该锁,那么线程1这次自旋也是很有可能能够再次成功的获得该锁,所以会适当的延长线程1的自旋次数。对应的,如果对于某一个锁,一个线程自旋后很少有机会获得该锁,那么以后该线程要获取该锁时直接忽略掉自旋过程,直接升级为重量级锁,以免长时间自旋造成资源浪费。
3.重量级锁
轻量级锁膨胀后成为重量级锁,依赖对象内部的monitor锁来实现。在jdk1.6之前监视器锁(monitor)可以认为直接对应底层操作系统中的互斥量(mutex)。也就是说monitor依赖操作系统的MutexLock(互斥锁)来实现,所以重量级锁也称为互斥锁。这种同步方式的成本非常高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。故这种监视器锁就被称为重量级锁。
为什么说重量级锁的开销大?
答:当系统检查到锁是重量级锁后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不消耗cpu,但是阻塞或者唤醒一个线程时都需要操作系统来帮忙,即从用户态转换到内核态。这个转换是要消耗很多时间的,有可能比用户执行代码的时间还要长。
小结:synchronized并非一开始就给该对象加上重量级锁,而是从偏向锁到轻量级锁再到重量级锁的演变。假如我们一开始就知道某个同步代码块竞争很激烈的话,那么我们一开始就要使用重量级锁,从而减少锁转换的开销。如果我们只有一个线程在运行,那偏向锁则是一个很好的选择。而当某个同步代码块竞争不是那么很激烈的时候,我们就可以考虑使用轻量级锁。