由面试题“并发编程的三个问题”深入浅出Synchronied

在面试的时候经常会问道一个问题

并发编程的三个问题是什么??

那么我在这里先回答这个问题的答案,有三个问题,可见性,原子性还有有序性。

  • 可见性:一个线程对一个主内存中的数据进行修改,其他线程也可以第一时间访问到的。
  • 原子性:一个或者多个操作并行时,要不全部操作都执行成功,要不一个操作出现异常,其他操作都不执行成功。
/**
*案例演示:五个进程每一个都进行一千次number++操作
*/
Runnable runnable = ()->{
            synchronized (object) {
                for (int i = 0; i < 1000; i++) {
                number++;
                }
            }

        };

        List<Thread> list = new ArrayList<>();

        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(runnable);
            thread.start();
            list.add(thread);
        }


        for (Thread lists:
             list) {
            lists.join();
        }

        System.out.println(atomicInteger.get());
    }

这一段number++代码的添加操作在jvm虚拟机中的指令是这样的

9:getstatic      #12 //Fieldnumber:I
12:iconst_1
13:iadd
14:putstatic  #12//Fieldnumber:I

假如当A线程执行了getstatic之后,进行了iconst_1,之后iadd后,突然cpu调度到了B线程,B线程走完了这四个指令,使得主内存中的number为1了之后,cpu又调度到A线程,这时候存入主内存的number还是1,虽然两个线程都执行了一次,结果应为2,但是最后还是1.

  • 有序性:操作的执行是按照顺序执行的,但是事实并不是这样,因为jvm会对方法进行优化,就会出现指令的重排序,在不影响逻辑和运行结果的时候对方法的操作进行一定的优化

我们要注意的是,对于并发编程中,由于cpu的调度问题,经常会出现原子性的问题,当一个线程对一个共享变量执行到一半的指令的时候,突然调度,干扰了前一个线程的操作。

刚刚在可见性中提到了主内存,那我就不得讲一讲java的内存模型了

java内存模型

java内存模型
首先有两个概念,主内存和工作内存

  • 主内存是所有线程都共享的,所有共享的变量都储存在主内存中
  • 工作内存是每一个线程有自己的工作 内存,工作内存只储存该线程对共享变量的副本,线程对变量的所有的读取操作都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量

那么工作内存在从主内存获得数据的时候,主内存会先有个lock操作,这个操作会先把工作内存之前的数据清除掉,然后在进行read,之后load加载到工作内存中进行数据的操作,最后在重新压入写入到主内存中,这时候主内存会有一个释放锁unlock的操作,在这个操作之前,工作内存的数据需要写入主内存才可以。

主内存到共享内存的交互过程为Lock->Read->Road->Use->Assign->Store->Write->Unlock

这些都是题外话。那Synchronized是怎么解决这三个问题的呢。

Synchronized如何解决并发编程的问题

  • 首先是可见性,由于不能及时的获取最新的数据,那么我们就需要给他在代码块上添加Synchronized锁,就可以保证数据可以被及时更新。

 public static  volatile boolean flag =true;
 public static Object object = new Object();


 new Thread(()->{
            synchronized (object){
            while (flag){
                System.out.println(flag);
            }}
        }).start();

        Thread.sleep(2000);

        new Thread(()->{
            while (flag){
                flag = false;

                System.out.println("更改flag为false");

            }
        }).start();

还有其他的方法,比如说使用volatile来修饰变量,再或者用一些加了锁的方法来操作这个变量,也可以进行变量值的更新,比如最简单的,加个 System.out.println();

  • 其次是原子性,也是在代码块或者同步方法直接加锁即可,这样就可以保证同一时间只有一个线程进入这个方法。
 public static int number;
public static  Object object = new Object();
 Runnable runnable = ()->{
            synchronized (object) {
                for (int i = 0; i < 1000; i++) {
                    number++;
                }
            }

        };

        List<Thread> list = new ArrayList<>();

        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(runnable);
            thread.start();
            list.add(thread);
        }


        for (Thread lists:
             list) {
            lists.join();
        }
  • 最后有序性,这个问题加了锁也不能解决本身的重排序,但是只要解决了原子性,那么不管再怎么重排序,也不会对数据的结果进行改变(不信的可以自己跑一遍试试哦。)

为什么会有重排序

为了提高程序的执行效率,编译器和CPU会对程序中代码进行重排序。
会遵循as-if-serial语义,意思是:不管编译器和CPU如何重排序,必须保证在单线程情况下程序的结果是正确的。以下数据有依赖关系,不能重排序。

写后读:

int a = 1;
System.out.println();

写后写:

int a = 1;
int a = 2;

读后写:

int a = 1;
int b = a;
int a = 2;

编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。

//这种操作 第一行第二行就可以重排序
int a =1 ;
int b = 2;
int c = b+a ;

但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

synchronized的特性

那么接下来就是有一些深度的了,关于synchronized的特性我这边总结两点

  • 可重入性:指一个线程在获取不同锁的过程中,遇到用于做锁的对象是相同的,那么在获取到第一个锁之后,其他的锁也可以直接进入,不用在进行获取锁的操作了。期底层会有一个计数器recurisons,每当重入一个锁,recurisons就会执行+1的操作,当执行完了一个锁的代码块的方法,recurisons则会-1,当recurisons为0时,则会一起释放改线程占有的所有的锁。

  • 不可中断:则是一个被阻塞的线程不可以通过stop等指令进行停止,当然可以使用lock锁,其中的trylock方法来判断等操作最后进行中断。

    tryLock实现可中断以上就是tryLock的方法,线程t1首先进入了线程方法run,线程t2拿不到锁,则进入等待池中,三秒之后tryLock检测t2还没获取到锁,即让他结束了线程。

我们刚刚在说可重入性的时候,提到了recurisons计数器,那么Synchronized底层到底是如何实现的呢。

Monitorenter指令

在jvm中,真正起作用的并不是Synchronized,而是会创建两个指令,Monitorenter和Monitroexit。每当给代码块修饰一个synchronized时,都会给用作锁的对象创建一个Monitor,当然不是我们创建,而是由jvm来创建,其中有两个值,分别是owner:拥有这把锁的线程, recurisons会记录线程拥有锁的次数,当一个线程有monitor,其他线程就要等待。

但是在修饰同步方法的时候,jvm并不会生成Monitorenter,而是会增加ACC_SYNCHRONIZED的修饰,会隐形调用monitorenter和monitorexit指令,在执行同步方法前会调用monitorenter,在执行完同步方法后会调用monitorexit。

jdk6对synchronized的优化

首先讲一讲悲观锁和乐观锁

悲观锁从被关的角度出发:

​ 总是假设最坏的情况,每次去拿数据的时候都会认为别人会修改,所以每次拿数据都会上锁,这样别人想拿的时候就会被阻塞,因此synchronized就是个悲观锁。

乐观锁从乐观的角度出发:

​ 总是假设最好的情况,每次拿数据都会认为别人不会修改,就算改了也没关系,重试即可,但是在更新的时候回判断在此期间有没有其他线程修改这个数据,如果没有则修改数据,有线程修改就重试。

我们所知的Synchronied则属于悲观锁,而乐观锁的典型的就是CAS

CAS的作用可以将比较和交换 转换为原子操作,这个原子操作直接由cpu保证。可以保证共享变量赋值时的原子操作,cas操作依赖三个值,内存中的值v,旧的预估值x和要修改的新值b,如果旧的预估值x等于内存中的值v,就将新的值b存到内存中。 因为没有使用synchronized,所以性能会好,但是不适用于竞争激烈的场景,因为竞争激烈重试的次数也会变多,会降低效率


//CAS实现无锁并发
public static  Object object = new Object();
public static AtomicInteger atomicInteger = new AtomicInteger();
    @Test
    public static void main(String[] args) throws InterruptedException {
        Runnable runnable = ()->{
            synchronized (object) {
                for (int i = 0; i < 1000; i++) {
                atomicInteger.incrementAndGet();
                }
            }

        };

        List<Thread> list = new ArrayList<>();

        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(runnable);
            thread.start();
            list.add(thread);
        }


        for (Thread lists:
             list) {
            lists.join();
        }

锁的升级

众所周知,锁有四种级别,从低到高级别分别为

无状态锁->偏向锁->轻量级锁->(自旋锁)->重量级锁

每个锁只能升级,不能降级,但是可以被清除。

  • 无状态锁就是没有锁。
  • 偏向锁会偏向第一个获取到锁的线程,当线程第一个获取到锁的时候,虚拟机会把该对象头的标识设置为01,则设置成偏向锁,随后进行CAS操作的时候,会把线程的id存入mark word中,如果下次再进入锁的时候,就会先把线程的id和存入markword中的id进行对比,如果相同则可以直接进入方法不用进行其他的同步操作。

偏向锁只适用于没有竞争关系的锁,会大大的提高效率,但是在有竞争关系的锁,就会升级成轻量级锁

  • 轻量级锁:将对象的mark word栈帧到lock recod中,将mark word的更新指向lock recod的指针,好处就是在多线程的竞争的情况下,可以避免重量级锁引起的性能消耗。
  • 自旋锁:在轻量级锁升级成重量级锁的一个过渡,可以在规定时间规定次数重新尝试获取锁,时间和次数可以用jvm指令来实现更改,这样可以大大减少轻量级锁升级成重量级锁。

锁的消除

比如stringbuffer的append方法,是一个线程安全的方法,在一个对象中多次执行,但是jit检测到是不可能发生锁的竞争,而且也不会逃逸出这个方法,这时候代码块再加锁就没意义,就会把append里的锁进行消除

锁粗化

jvm会探测到一连串细小的操作都使用同一个对象加锁,则将同步代码块的范围放大,放到这串操作的外面,这样就只要加一次锁即可

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!