文章目录
一、Synchronized是什么?有什么用?
synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。同时我们还应该注意到synchronized另外一个重要的作用,synchronized可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代Volatile功能)。
在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
不过,在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized 。
关于锁的升级流程,大家可以去看我的另一篇文章:synchronized锁的升级流程
二、为什么要使用Synchronized
使用synchronized来保证线程安全,解决多线程环境下可能出现的并发问题,避免出现数据不一致的情况。
对于临界资源没有使用synchronized进行同步的情况:
public class test implements Runnable{
//共享资源(临界资源)
static int i=0;
public void increase(){
i++;
}
@Override
public void run() {
for(int j=0;j<10000;j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
test instance=new test();
//这里创建了两个线程对i进行+1的操作。完美情况下,输出结果应该是i=20000
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("i="+i);
}
}
输出的结果为:i=1094716
。
为什么输出结果和预计结果会不一致呢?为什么是比预计结果更小而不是更大呢?
答:由于 i++; 操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成。如果第一个线程此时读取到了 i 的值,尚未写回 i+1 这个新值,此时第二个线程到来,读取到的也是 i 值,然后写回的也是 i+1 值。相当于本来两个线程各执行一次 i+1 操作,结果只执行了一次 i+1 操作。所以结果会被预期的更小。
三、如何使用synchronized?
synchronized
关键字的使用方式主要有下面 3 种:
- 修饰实例方法
- 修饰静态方法
- 修饰代码块
1. 修饰实例方法(锁当前对象实例)
给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁 。
public class test01 implements Runnable{
//共享资源(临界资源)
static int i=0;
/**
* synchronized 修饰实例方法
*/
public synchronized void increase(){
i++;
}
@Override
public void run() {
for(int j=0;j<10000;j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
test01 instance=new test01();
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
输出结果为:i=20000
.避免了数据出现不一致的情况。使用synchronized修饰实例方法锁住了该对象实例,一个线程获得了该对象实例的锁,其他线程就无法获得了。但是要注意,如果是不同对象实例,那么锁住的对象就不一样。比如一个线程 A 需要访问实例对象 obj1 的 synchronized 方法 f1(当前对象锁是obj1),另一个线程 B 需要访问实例对象 obj2 的 synchronized 方法 f2(当前对象锁是obj2),这样是允许的,这样依旧会导致数据安全情况的发生。以下代码演示该情况:
public class test01 implements Runnable{
//共享资源(临界资源)
static int i=0;
/**
* synchronized 修饰实例方法
*/
public synchronized void increase(){
i++;
}
@Override
public void run() {
for(int j=0;j<10000;j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(new test01());
Thread t2=new Thread(new test01());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
输出结果为:i=19956
.虽然我们锁住了increase()方法,却new了两个不同的实例对象,这也就意味着存在着两个不同的实例对象锁,因此t1和t2都会进入各自的对象锁,也就是说t1和t2线程使用的是不同的锁,所以依然会出现数据不安全的情况。
解决方案:使用synchronized修饰静态的increase()方法。详情如下:
2.修饰静态方法 (锁当前类)
给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。
这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。
public class test01 implements Runnable{
//共享资源(临界资源)
static int i=0;
/**
* synchronized 修饰静态方法
*/
public synchronized static void increase(){
i++;
}
@Override
public void run() {
for(int j=0;j<10000;j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(new test01());
Thread t2=new Thread(new test01());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
输出结果为:i=20000
.此时锁住的是当前class而不是对象。
静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类(class)的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。是不同的锁,所以不会发生互斥的情况。
3. 修饰代码块 (锁指定对象/类)
对括号里指定的对象/类加锁:
- synchronized(object) 表示进入同步代码库前要获得 给定对象的锁。
- synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁。
public class test01 implements Runnable{
static test01 object=new test01();
//共享资源(临界资源)
static int i=0;
@Override
public void run() {
//使用同步代码块对变量i进行同步操作
synchronized(object){
for(int j=0;j<10000;j++){
i++;
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(object);
Thread t2=new Thread(object);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
输出结果为:i=20000
.
使用synchronized修饰代码块可以让锁的粒度更小。在很多情况下,我们并不是整个方法都需要保证同步操作,例如一些读取操作,不存在数据不一致的情况。这种时候我们就可以使用synchronized仅仅修饰需要保证数据同步的那部分代码块,可以使性能得到提升。
四、Synchronized的底层原理
synchronized 关键字底层原理属于 JVM 层面的东西。
1. 同步语句块
public class test{
public void method() {
synchronized (this) {
System.out.println("synchronized同步代码块");
}
}
}
准备步骤:
- 打开类对应目录下的终端
- 输入
javac -encoding UTF-8 test.java
- 输入
javap -c -s -v -l test.class
- 开始查看字节码:
从上面我们可以看出:synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
为什么会有两个monitorexit指令?为什么保证同步代码块不管是正常执行发生执行期间发生异常锁都可以被正确释放。
当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。每个 Monitor 内部都有一个计数器,用于跟踪锁的重入次数。当线程第一次获取锁时,计数器设为1。如果同一个线程再次获取同一把锁,计数器会增加。当线程退出同步代码块时,计数器减1,只有当计数器回到0时,锁才会被完全释放。
如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
2. 同步方法
public class test{
public synchronized void method() {
System.out.println("synchronized 方法");
}
}
查看字节码和上一个方法一样。
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的却是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。
总结
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
synchronized 修饰方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,用来保证互斥访问。