1、表现形式
- 修饰非静态方法,锁是当前实例对象,作用范围是整个方法。
- 修饰静态方法,锁是当前类的Class对象,作用范围是整个静态方法。
- 修饰代码块,锁是Synchronized括号里配置的对象,作用范围是大括号括起来的代码。
2、具体使用
2.1 修饰非静态方法
public synchronized void method() {
}
public void method2() {
synchronized(this) {
}
}
- synchronized关键字不能被继承,如果在父类中的某个方法使用了synchronized关键字,而在子类中重写了这个方法,子类中的这个方法默认不是同步的,必须在子类的这个方法中显式的添加synchronized。
- 在定义接口方法时不能使用synchronized关键字。
- 构造方法不能使用synchronized关键字,但可以使用synchronized代码块来进行同步。
2.2、修饰静态方法
public synchronized static void method() {
}
- 同一个类的不同对象,在并发时调用相同的同步静态方法会保持线程同步,因为同步静态方法属于类,同一个类的不同对象相当于用了同一把锁。
2.3、修饰代码块
class TestClass {
public void methodA() {
// 锁住当前实例
synchronized(this) {
// todo
}
}
public void methodB() {
// 锁住当前类
synchronized(TestClass.class) {
// todo
}
}
private byte[] lock = new byte[0];
public void methodC() {
// 锁住特定对象
synchronized(lock) {
}
}
}
- 当多个并发线程同时访问同一个对象时,在同一时刻只能有一个线程得到执行,其它线程受阻塞。如果创建了多个对象,不同对象的锁是互不干扰的,不形成互斥,多个线程可以同时执行。
- 当一个线程访问对象的synchronized(this)同步代码块时,另一个线程仍然可以访问该对象的非synchronized(this)代码块,并且不受阻塞。
- 当没有明确的对象作为锁,只是想让一段代码块同步时,可以创建一个特殊的对象来充当锁,比如长度为零的byte[]。
- methodA()中synchronized(this)作用于类对象,效果和给非静态方法加synchronized相同。
- methodB()中synchronized(TestClass.class)作用于类,是给这个类加锁,类的所有对象用的是同一把锁。
3、原理
3.1 synchronized修饰代码块
synchronized通过互斥来保证并发的正确性,synchronized经过编译后,会在同步代码块前后形成monitorenter和monitorexit这两个字节码。monitorenter指令指向同步代码块的开始位置,monitorexit指令指向同步代码块的结束位置。
当执行monitorenter指令时,首先尝试获取对象的锁,如果当前对象没有被锁定,或者当前对象已经拥有对象锁,则把锁的计数器加1,在执行monitorexit时会将锁的计数器减1。当计数器为0时,锁会被释放。如果获取锁的对象失败,则当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
3.2 synchronized修饰方法
synchronized 修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是 ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM 通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
4、Java对象头
synchronized用的锁存在Java对象头中,如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit。
HotSpot虚拟机的对象头分为两个部分,第一部分用于存储对象自身运行时的数据,包括哈希码、GC分代年龄和锁标记位,称为Mark Word。另一部分用于存储指向方法区的对象类型的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
5、锁优化
锁有四种状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,随着竞争的激烈而逐渐升级。锁可以升级但不可降级,这种策略是为了提高获得锁和释放锁的效率。
5.1 偏向锁
偏向锁的意思是偏向于第一个获得它的线程,当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,如果在接下来的执行中,该锁没有被其他线程获取,那么持有偏向锁的线程就不需要进行同步。
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
5.2 轻量级锁
如果获取偏向锁失败,虚拟机会尝试使用轻量级锁。轻量级锁不是为了代替重量级锁,本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,因为使用轻量级锁时,不需要申请互斥量。轻量级锁的加锁和解锁都用了CAS操作。
如果没有锁竞争,轻量级锁使用CAS操作避免了使用互斥操作的开销。但如果存在锁竞争,除了互斥量开销外,还有额外的CAS操作。因此,如果锁竞争激烈,轻量级锁会很快膨胀为重量级锁。
5.3 自旋锁
轻量级锁失败后,虚拟机为了避免线程在操作系统层面挂起,还会进行自旋锁的优化。
互斥同步对性能最大的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要转入内核态中完成,用户态切换到内核态费时。
一般线程持有锁的时间都不是很长,所以仅仅为了这一点时间去挂起线程/恢复线程是得不偿失的。为了让一个线程等待,只需要让线程执行一个忙循环,这项技术就叫自旋。
JSDK1.6之前自旋锁默认关闭,需要通过--XX:+UserSpinning参数开启,JDK1.6及之后,改为默认开启。自旋等待还是会占用处理器时间,如果锁被占用的时间短,效果会很好。反之,相反。自旋次数的默认值是10次,用户可以修改--XX:PreBlockSpin来修改。
在JDK1.6中引入了自适应的自旋锁,自旋的时间不再固定了,而是和前一次同一个锁上的自旋时间以及锁的拥有者的状态来决定。
5.4 锁的优缺点对比
| 锁 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
| 轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁,竞争的线程使用自旋会消耗CPU | 追求响应时间,同步块执行速度非常快 |
| 重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较长 |
6、synchronized和ReentrantLock对比
6.1 两者都是可重入锁
可重入锁意思是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象还没释放,当其想要再次获取这个对象的锁时还是可以获取的。如果是不可重入锁,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器降为0时才能释放锁。
6.2 synchronized依赖于JVM,ReentrantLock依赖于API
synchronized是依赖于JVM实现的,并没有直接暴露给我们。ReentrantLock是JDK层面实现,也就是API层面,需要lock()和unlock()方法配合try/finally语句块来完成。
6.3 ReentrantLock比synchronized增加了一些高级功能
####(1)等待可中断
通过lock.lockInterruptibly()实现,正在等待的线程可以选择放弃等待,改为处理其它事情。
####(2)可实现公平锁
ReentrantLock可以指定公平锁还是非公平锁,而synchronized只能是非公平锁。公平锁就是先等待的线程先获得锁。ReentrantLock模式是非公平锁,可以通过ReentrantLock(boolean fair)构造方法选择是否公平。
####(3)可实现选择性通知
synchronized关键字与wait()和notify()/notifyAll()方法相结合,可以实现等待/通知机制,但是被通知的线程是由JVM选择的。
ReentrantLock需要借助Condition接口和newCondition()方法,一个Lock对象可以创建多个Condition实例(对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。
如果执行notifyAll()方法就会通知所有处于等待状态的线程,这样就会造成很大的效率问题,而Condition实例的signalAll()方法只会唤醒注册在该Condition实例中的所有等待线程。