您的位置:首页 > 职场人生

手撕面试官-synchronized

2020-04-22 00:18 686 查看

认识锁

上文我们详细介绍了volatile关键字,能够解决指令重排和可见性的问题。但是它并不能够解决原子性的问题。接下来我们将介绍synchronized关键字。synchronized关键字可以用来解决因为CPU切换而导致的原子性问题。

synchronized就是我们经常说到的“锁”,如果说后续听人说“管程”也是它。既然是锁,那么我们通过简单的图片来看下锁的场景:

我们可以这样去理解锁的概念。当很多线程要进行同一个操作时候。那么要保证这个操作的原子性。我们可以为这些线程创建一把公共的锁。哪个线程获取到了锁,就有权利霸占执行权。其他没有拿到锁的线程只能等拿到锁的线程把锁释放了才可以继续抢锁。所以同一时刻只会有一个线程执行临界区的操作。一定不会被中断。那么原子性问题就解决了。

相信小伙伴肯定都见过synchronized有这么用的: synchronized(object),还有这么用的,public synchronized void method(){},还有这么用的 public synchronized static void method()。
那么有什么区别吗?
1,synchronized(object) 锁住的对象是object。
2,synchronized修饰静态方法,锁住的是class对象。
3,synchronized修改非静态方法,锁住的是这个类的实例对象。可以理解为this。

使用锁

下面我们通过几个例子,来演示下synchronized的使用和注意事项。
1,解决count += 1的问题:

public class StackTest {
private volatile long count = 0;

private synchronized void addMoney(){
for (int i = 0; i < 10000; i++){
count += 1;
}
}

public synchronized long getCount(){
return count;
}

public static void main(String[] args) throws Exception {
StackTest stackTest = new StackTest();

Thread thread1 = new Thread(() ->{
stackTest.addMoney();
});

Thread thread2 = new Thread(() ->{
stackTest.addMoney();
});

thread1.start();
thread2.start();

thread1.join();
thread2.join();

System.out.println(stackTest.getCount());
}
}

这段代码我们在《别问我多线程为啥有bug了》运行结果总是不如人意。上面我们学了volatile。今天我们学了synchronized。那么我们将count用volatile修饰。防止因为可见性和指令重排导致bug,然后我们将addMoney()和getCount()方法都用synchronized修饰。再次执行发现已经符合预期了。这里面需要一个场景,当多线程操作同一个资源时,如果加锁,难么对于多个线程来说应该加公共的锁。比如我再举个例子。

2,错误的加锁示范

public class InvalidLock {

private volatile long count = 0L;

public long getCount(){
synchronized (new Object()){
return count;
}
}

public void addCount(){
synchronized (new Object()){
count +=1;
}
}

public static void main(String[] args) throws Exception {
InvalidLock invalidLock = new InvalidLock();

Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++){
invalidLock.addCount();
}
}
});

Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++){
invalidLock.addCount();
}
}
});

thread1.start();
thread2.start();

thread1.join();
thread2.join();

System.out.println(invalidLock.getCount());
}
}

这段代码看下来也加锁了,也用volatile修改的变量。那么结果会不会是2000呢。如果不是问题又出在哪里了呢。
其实可以发现每次执行加锁操作时候,两个线程的锁并不是同一把锁。(new Object())。所以出了问题。

探究锁-锁的膨胀过程

那么synchronized的底层是怎么做到保持原子性的呢。而且我们还会经常听别人说synchronized是个重量级锁,要考虑性能,那么它有没有被优化呢?
同volatile一样,synchronized的实现是jvm实现的,也就是说你看不到源码(后面我们会介绍一个可以看到源码的锁)

这里只介绍synchronized代码块的实现方式。它的加锁和解锁是通过两条指令实现的:1,monitorenter, 2,monitorexit。在编译后,JVM会在临界区插入这两条指令(注意monitorexit指令在异常处也要插入,否则会因为程序异常导致锁资源不能释放),在java中。任何一个对象都会有一个monitor对象。当一个对象与之对应的monitor对象被持有后,这个对象就处于加锁状态。而线程执行到monitorenter指令时正是要试图持有“代码中被锁对象”的monitor对象。

假如一个对象被锁住,锁存在哪里呢,java怎么去区分哪个线程持有了锁呢,从而阻塞其他没锁的线程。
一句话:锁 存在于java对象头中的 Mark Word中。Mark Word中默认存储对象的HashCode, 分代年龄(后续将jvm时候,会用到)和锁标记

简单说下1.6版本以前的synchronized关键字,之所以称它为重量级的锁是因为那个时候的锁等待是阻塞式等待。即:没有拿到锁的线程直接进人blocked状态。然后锁被持有锁的线程释放后,blocked状态的线程又要重新竞争锁。所以在多并发情况下,这样的状态切换代价还是很大的。而1.6以后,synchronized由1个锁变为3个锁(偏向锁,轻量级锁,重量级锁)。并且竞争锁的方式也引入了CAS竞争锁。变成了非阻塞式等待。所以才说优化过后的synchronized不再那么重量级。

偏向锁
偏向锁的引入是因为,经过HotSpot的作者分析,在锁的竞争中,不仅仅是多线程竞争,存在很多情况下锁都是同一个线程去获取。为了让线程获得锁的代价更低,从而引入偏向锁。

与我们上面说的加锁解锁过程稍微有区别的是,偏向锁的解锁是出现竞争才会释放锁。

偏向锁在java6和java7中是默认开启的。但是需要在应用程序启动几秒钟之后才会激活。通过JVM参数:
-XX:BiasedLockingStartupDelay=0可以关闭延迟。但是如果已经确定程序中所以的锁通常是竞争状态,那么偏向锁就没有必须了,可以通过JVM参数:-XX:-UseBiasedLocking=false,关闭偏向锁。

轻量级锁

轻量级锁加锁:
线程在执行同步代码块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,然后线程尝试CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程便获得锁,否则自旋获取锁。
轻量级锁解锁:
解锁时,使用CAS操作将Mark Word替换回对象头,如果成功,则表示没有竞争性,如果失败,表示存在锁竞争,那么就会发生锁膨胀。

三种锁的使用场景

探究锁-原子性如何保证

处理器常规的保证原子性是通过总线锁定和缓存锁定来实现的。
总线锁定:
多个线程对同一个共享变量count进行count++。这是个经典的读-改-写操作。我们在之前也介绍过这个操作会因为是非原子性操作而导致结果并非预期。
那么当处理器使用总线锁时,会提供一个LOCK#信号,当一个处理器在总线上输出此信号时,其它处理器的请求将会被阻塞,这样就达到了该处理器独享内存的效果。

补充一下cpu流水线的概念:
cpu流水线中由5-6个电路单元组成一条指令处理流水线,然后分别执行。达到在一个时间片内完成一条指令,提高效率。大概可以理解为,cpu是个工厂。工厂有5个人。当执行一条指令时,5个人分工执行。效率就很高。如果总线被锁定,就代表工厂被其中一个财主霸占,这个工厂(流水线)只听他的。所以这个财主(cpu)可以为所欲为。

那你可能就发现问题了,如果其他cpu不想操作共享变量岂不是也被阻塞在外面了,这样效率岂不是很低。于是就出现了缓存锁定。
缓存锁定:
我们知道频繁使用的数据会在三级缓存中缓存。那么当处理器准备修改缓存数据时候,直接去修改内存中的数据,再通过缓存控制器,使其他缓存该地址数据的缓存无效从而保证原子性的实现。类似于。多个线程透过缓存直接操作内存,而内存只有一份。所以规避了原子性的问题。

  • 点赞
  • 收藏
  • 分享
  • 文章举报
bugdaybyday 发布了5 篇原创文章 · 获赞 0 · 访问量 148 私信 关注
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: