synchronized

87 阅读8分钟
Sychronized关键字时需要把握如下注意点:
  • 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待;
  • 每个实例都对应有自己的一把锁(this),不同实例之间互不影响;例外:锁对象是*.class以及synchronized修饰的是static方法的时候,所有对象公用同一把锁
  • synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁
对象锁
  • 方法锁【默认锁对象为this,当前实例对象】
  • 同步代码块【自己指定锁对象】
public class SynchronizedObjectSample implements Runnable{
    public synchronized void hello() throws InterruptedException {
        System.out.println("同步静态方法对象锁:" + Thread.currentThread().getName() + "开始执行");
        TimeUnit.SECONDS.sleep(1);
        System.out.println("同步静态方法对象锁:" + Thread.currentThread().getName() + "执行完毕");
    }
    public void hello_1() throws InterruptedException {
        synchronized (this){
            System.out.println("同步代码块对象锁:" + Thread.currentThread().getName() + "开始执行");
            TimeUnit.SECONDS.sleep(1);
            System.out.println("同步代码块对象锁:" +Thread.currentThread().getName() + "执行完毕");
        }
    }

    @Override
    public void run() {
        try {
            hello_1();
            hello();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    public static void main(String[] args) {
        SynchronizedObjectSample synchronizedSample = new SynchronizedObjectSample();
        new Thread(synchronizedSample).start();
        new Thread(synchronizedSample).start();
    }
}
同步代码块对象锁:Thread-0开始执行
同步代码块对象锁:Thread-0执行完毕
同步静态方法对象锁:Thread-0开始执行
同步静态方法对象锁:Thread-0执行完毕
同步代码块对象锁:Thread-1开始执行
同步代码块对象锁:Thread-1执行完毕
同步静态方法对象锁:Thread-1开始执行
同步静态方法对象锁:Thread-1执行完毕
类锁
  • 指synchronize修饰静态的方法
  • 指定锁对象为Class对象
public class SynchronizedSample implements Runnable{
    public static synchronized void hello() throws InterruptedException {
        System.out.println("同步静态方法类锁:" + Thread.currentThread().getName() + "开始执行");
        TimeUnit.SECONDS.sleep(1);
        System.out.println("同步静态方法类锁:" + Thread.currentThread().getName() + "执行完毕");
    }
    public void hello_1() throws InterruptedException {
        synchronized (SynchronizedSample.class){
            System.out.println("同步代码块类锁:" + Thread.currentThread().getName() + "开始执行");
            TimeUnit.SECONDS.sleep(1);
            System.out.println("同步代码块类锁:" +Thread.currentThread().getName() + "执行完毕");
        }
    }

    @Override
    public void run() {
        try {
            hello_1();
            hello();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    public static void main(String[] args) {
        SynchronizedSample synchronizedSample = new SynchronizedSample();
        SynchronizedSample synchronizedSample1 = new SynchronizedSample();
        new Thread(synchronizedSample).start();
        new Thread(synchronizedSample1).start();
    }
}
同步代码块类锁:Thread-0开始执行
同步代码块类锁:Thread-0执行完毕
同步代码块类锁:Thread-1开始执行
同步代码块类锁:Thread-1执行完毕
同步静态方法类锁:Thread-1开始执行
同步静态方法类锁:Thread-1执行完毕
同步静态方法类锁:Thread-0开始执行
同步静态方法类锁:Thread-0执行完毕
Synchronized本质上是通过什么保证线程安全的? 分三个方面回答:加锁和释放锁的原理,可重入原理,保证可见性原理。

加锁和释放锁的原理

  • 使用javac命令进行编译生成.class文件
javac SynchronizedSample.java
  • 使用javap命令反编译查看.class文件的信息
javap -verbose SynchronizedSample.class

可以看到MonitorenterMonitorexit指令,会让对象在执行,使其锁计数器加1或者减1。每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的Monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一:

  • monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待
  • 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加
  • 这把锁已经被别的线程获取了,等待锁释放

可重入原理

  • 同一个线程重入时,monitor计数器累加

可见性原理

  • Synchronized的happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁

JVM 中锁优化

简单来说在JVM中monitorentermonitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。不过在jdk1.6中对锁的实现引入了大量的优化来减少锁操作的开销

  • 锁粗化(Lock Coarsening)

原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。

大部分上述情况是完美正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能操作。

  • 锁消除(Lock Elimination)

JVM会判断在一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那JVM就把它们当作栈上数据对待,认为这些数据是线程独有的,不需要加同步。此时就会进行锁消除

  • 轻量级锁(Lightweight Locking)

在线程执行同步块之前,JVM会先在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(JVM会将对象头中的Mark Word拷贝到锁记录中,官方称为Displaced Mark Ward)

然后,虚拟机使用CAS操作将标记字段Mark Word拷贝到锁记录中,并且将Mark Word更新为指向Lock Record的指针。如果更新成功了,那么这个线程就拥用了该对象的锁,并且对象Mark Word的锁标志位更新为(Mark Word中最后的2bit)00,即表示此对象处于轻量级锁定状态

  • 偏向锁(Biased Locking)

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁

  • 适应性自旋(Adaptive Spinning)
锁的状态

锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)

  • 无锁
  • 偏向锁
  • 轻量级锁
  • 重量级锁

锁的优缺点对比

优点缺点使用场景
偏向锁加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块的场景
轻量级锁竞争的线程不会阻塞,提高了响应速度如线程始终得不到锁竞争的线程,使用自旋会消耗CPU性能追求响应时间,同步块执行速度非常快
重量级锁线程竞争不适用自旋,不会消耗CPU线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗追求吞吐量,同步块执行速度较长
Synchronized与Lock

synchronized的缺陷

  • 效率低:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时
  • 不够灵活:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活
  • 无法知道是否成功获得锁,相对而言,Lock可以拿到状态,如果成功获取锁,....,如果获取失败,.....

Lock解决相应问题

  • Synchronized加锁只与一个条件(是否获取锁)相关联,不灵活,后来Condition与Lock的结合解决了这个问题。
  • Synchronized多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。ReentrantLock的lockInterruptibly()方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后ReentrantLock响应这个中断,不再让这个线程继续等待。有了这个机制,使用ReentrantLock时就不会像synchronized那样产生死锁了。