摘要
在Java程序涉及多线程的同步问题的时候,往往需要给线程同步安全的功能加锁,可能是需要同步一个方法,也可能只是一段代码。
synchronzied关键字介绍:
其中synchronized关键字就是Java内部实现的一个锁机制,通过给需要添加锁机制的方法或代码块加上synchronzied关键字,这样就能保证同一时间内只允许一个线程执行这段代码。
synchronized块是Java提供的一种原子内置锁,Java程序中的每个对象都可以把它当作一个同步锁来使用(也就是锁,我们实例的所有对象,或着是class对象,都可以当作一把锁),这些Java内置的,我们看不到的锁一般称为内部锁,也叫监视器锁。
同步过程:
线程执行到synchronized代码块时,会先自动获取内部锁,这个时候,如果其他线程也想访问这部分代码块的时候就会获取锁失败,进入阻塞状态。拿到内部锁的线程会在 :正常退出同步代码块、抛出异常后、在同步块内调用了该内置锁的wait等方法以上三种情况下释放内置锁。因为这把内置锁是属于排他锁,所以其他线程必须等到内置锁被释放后才能获取到内置锁。
这样就实现了原子性操作。
synchronized的内存语义:
进入synchronized块的内存语义:把synchronized块内使用到的变量从工作内存中清除,从主内存中直接获取。
退出synchronized块的内存语义:把synchronized块内对共享变量的修改刷新到主内存中。
这样就保证了共享变量的可见性。
synchronized的使用:
1.把synchronized关键字用在非静态方法内部,这时候获取的锁是:实例对象的锁
2.把sychronized关键字用作静态方法内部,这时候获取的锁是:该Class对象的锁
3.sychronized关键字包住一部分代码块,获取的锁是括号里面的锁
简单代码演示:
/**
* 获取实例对象的锁
*/
public synchronized void testSynchronized(){
//需要同步的内容.......
//我是要同步的内容.....
}
/**
* 获取的是该类的Class对象的锁
*/
public static synchronized void testStaticSynchronized(){
//需要同步的内容.......
//我是要同步的内容.....
}
/**
* 获取的是括号内部对象的锁,一般会传入this,
* 因为一直new obj有很多个,不是唯一的,就达不到锁的功能了
*
*/
public void testInSynchronized(){
Object obj = new Object();
//传入obj对象的锁,一般为this,也就是该方法所在对象的实例对象
synchronized (obj){
//需要同步的内容.....
//我是要同步的内容......
}
}
性能的开销:
因为Java程序的线程是与我们操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,是一种很耗时的操作,synchronized的使用就会导致线程阻塞,导致上下文的一个切换。因此我们在使用多线程的时候往往要注意合理的使用锁,避免系统性能大幅度下降。
实现原理
synchronized是JVM内置锁,通过内部对象Monitor(监视器锁)实现,基于进入Monitor对象实现方法,基于进入与退出Monitor对象实现方法与代码块同步,但是两者实现细节不一样。监视器锁的实现依赖底层操作系统的Mutex Lock(互斥锁)实现。
同步代码块实现过程:准备执行同步代码块(尝试获取对象对应的monitor所有权)--monitorenter(获取锁成功,进入同步代码块) - 》 同步代码块逻辑 -》 monitorexit(程序退出同步代码块,释放锁)
JVM所有锁机制都是围绕Monitor(监视器锁)去实现的,每个同步对象都有一个自己的Monitor
synchronized用的锁是存在Java对象头里的,Mark Word里存储的数据会随着锁标志位变化而变化。
锁状态 | 25bit | 4bit | 1bit | 2bit | |
23bit | 2bit | 是否是偏向锁 | 锁标志位 | ||
轻量级标记 | 指向栈中锁记录的指针 | 00 | |||
重量级标记 | 指向互斥量(重量级锁)的指针 | 10 | |||
GC标记 | 空 | 11 | |||
偏向锁 | 线程ID | Epoch | 对象分代年龄 | 1 | 01 |
JDK1.5后对synchronized的优化:
锁状态:无锁、偏向锁、轻量级锁、重量级锁。
synchronized同步过程:
1、当线程进入同步代码块的时候,先会判断对象标记(Mark Word)中的锁状态,假如是程序刚运行处于无锁状态(锁标记为01,偏向锁标记为0),这时里面的ThreadId为0,说明这个时候没有线程获取到该对象的锁,ThreadId就会写入当前当前线程的ID,修改偏向锁标志位,线程获取到偏向锁,再下一次该线程重新进入这里的时候,就会先判断对象头里面的ThreadId是否跟当前线程的ID相等,是的话就会直接进入代码块运行,无须再次获取锁; (锁偏向,减少无并发时获取锁的次数,减少性能开销(只依赖一次CAS原子指令操作,更换ThreadId))
2、如果当别的线程尝试获取,发现ThreadId不一致(开始出现了竞争,这时候获取到偏向锁的线程准备撤销偏向锁),这个外来线程会陷入自旋(这时候锁膨胀为轻量级锁,Mark Word里面的锁改为轻量级锁),使用CAS进行不断尝试,一个空循环(实现方式); (这类锁称为自旋锁,多线程竞争不激烈,同步块执行快,自旋可以减少阻塞线程带来的线程上下文切换带来的性能开销(依赖多次CAS原子指令操作,尝试把对象头的Mark Word指向自身线程,如果成功,则锁竞争成功))
3、如果在一定自旋次数获取不到锁,这个时候说明锁竞争比较激烈,这个时候取消自旋锁,锁膨胀为重量级锁,等待锁的线程就会进入阻塞状态,在阻塞队列中等待锁释放(因为这时候自旋是无效的,一直自旋最后还是拿不到锁)
锁的优缺点比较:
锁 | 优点 | 缺点 | 适用场景 |
偏向锁 | 加锁和解锁不需要额外的消耗, 和执行非同方法基本一样 | 如果线程间存在锁竞争, 会带来额外的锁撤销的消耗 | 只有一个线程访问的同步块场景 |
轻量级锁 | 竞争的线程不会被阻塞, 提高了程序的响应速度 | 如果始终得不到锁的竞争线程, 使用自旋会消耗CPU | 追求响应时间 同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应缓慢 | 追求吞吐量 同步块执行时间稍长 |
锁粗化(扩大锁的范围)
如果JVM检测到在同一个地方经常加锁和解锁,它就会把这些加锁和解锁合并成一次,以减少频繁获取锁释放锁带来的性能开销。
锁消除(消除没有用的锁):
如果JVM发现同步代码块执行的地方并不需要锁也能正确运行,就会删除这些没有必要的锁。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。
参考书籍:《Java并发编程的艺术》,这里也推荐大家读一读,是本好书。