每日一道面试题(第6期)---如何实现多线程中的同步

零零碎碎的东西总是记不长久,仅仅学习别人的文章也只是他人咀嚼后留下的残渣。无意中发现了这个每日一道面试题,想了想如果只是简单地去思考,那么不仅会收效甚微,甚至难一点的题目自己可能都懒得去想,坚持不下来。所以不如把每一次的思考、理解以及别人的见解记录下来。不仅加深自己的理解,更要激励自己坚持下去。

在介绍多线程中的同步之前,我们先来了解下并发编程。

并发编程

并发编程存在的意义

  • 资源利用率:程序的进行,无非就是cpu对于一条条指令执行的操作。cpu对于指令进行处理的速度是非常快的,如果是串行的话,比如IO操作的速度是远远小于cpu操作的速度,这样cpu就会存在大量的空闲时间,浪费cpu资源。
  • 时间:有时多个任务之间并没有什么关联,在资源充足的情况下,他们完全可以并发的进行。串行的结果只能是延长完成总任务的时间,降低效率。
  • 公平性:同时申请需要进行的任务,随意的串行进行执行会打破任务之间的公平性。并发操作使任务可以同时进行,得到同样的资源,保证公平性。

并发编程的缺陷

  • 安全性:并发编程的安全性缺陷是众所周知的。不同的工作线程对同一资源同时进行处理会产生脏数据。典型的例子就是银行转账问题,在同一时间A向B的账户、B向A的账户转账,两个工作线程同时对余额进行操作,必定会导致操作过后对不上账的情况。
  • 开销:在单个cpu的情况下,并发操作是通过cpu频繁的切换线程达到并发的目的,线程的切换涉及到寄存器的数据保存、更新等操作,需要一定的开销。在高并发的情况下,需要考虑到并发操作节省的资源与时间是否可以弥补线程切换间的开销。
  • 复杂性:单个线程用到的资源可以从线程对应的栈、寄存器以及进程中的内存中获取,在执行时也不会与其他线程产生交集。多线程编程,势必会涉及到线程间的通信、数据同步等问题,一整套的并发机制设计起来很复杂。

并发编程的三大要素

  • 原子性:一个操作或者多个操作,要么全部执行,要么都不执行,执行过程中不可被任何因素打断。在java中对基本数据类型的赋值与读取是原子性操作。

  • 可见性:多个线程访问同一个变量时,一个线程改变了这个变量,其他线程都要对此值进行同步更新。

  • 有序性:即程序的执行顺序按照代码的先后顺序执行。这是因为处理器在执行程序时,为了优化程序执行效率,会对代码进行不同程度上的指令重排序,当然这种重排序不会改变程序最后运行的结果,因为不会对存在数据依赖的代码进行重排序。也就是下一行代码需要用到上一行代码的数据。在单线程中指令重排序没有任何问题,但是在多线程中就会出现问题。

    //线程1:
    context = loadContext();   //语句1
    inited = true;             //语句2
    
    //线程2:
    while(!inited ){
     sleep()
    }
    doSomethingwithconfig(context);

    线程1中语句1和语句2没有数据依懒性,inited仅是一个标记变量,所以这两个语句可能发生指令重排序。当语句2在语句1之前执行时,这是恰好线程2启动,标记变量init为true则线程2认为初始化已经完成,而此时语句1并没有执行,就会造成问题。

所以说,并发编程中保证原子性、可见性、有序性,是同步的基本要素

同步操作

volatile

volatile是java的一个关键字,一旦一个共享变量(类的成员变量、静态变量)被volatile关键字修饰,就具备有两层含义

  • 当一个线程对此变量的值进行了更新操作,那么对其他线程是可见的。这里的更新操作,指写入工作内存。
  • 禁止指令重排序。具体为volatile变量之前的代码不会被指令重排序到此变量之后,在程序执行到volatile变量时,之前的代码一定已经全部执行,之后的代码一定还没有执行。

这就保证了可见性与有序性,但是volatile并不保证可见性。看下面一段代码

public class Main {
    private volatile static int test = 0;
    private volatile static int count = 10;

    public static void main(String[] args) {
        for(int i=0;i<10;i++){
            Main mm = new Main();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int j=0;j<10000;j++){
                        mm.increase();
                    }
                    count--;
                }
            }).start();
        }
        while (count > 0){}//所有线程执行完毕
        System.out.println("最后的数据为" + test);
    }

    private void increase(){test++;}
}

运行后你会发现,每一次的结果都小于100000。这是因为test++这个操作,它不是原子性的,与test本身这个变量无关。

test++ 经过三个原子操作,读取test变量值、test变量进行加一操作、将操作后的变量值写入工作内存。当线程1执行到前两步时,线程2开始读取test变量值,当线程1三个步骤执行完毕时,虽然此时test的值会立马更新到线程2,但是线程2已经在此之前进行了读取变量值的操作,所以实际上两个线程只让test加了一次。

所以说,volatile只进行一些简单的同步操作,比如上面提到的标记变量

volatile boolean inited = false;
//线程1:
context = loadContext();   //语句1
inited = true;             //语句2

 //线程2:
while(!inited ){
   sleep()
}
doSomethingwithconfig(context);

并发编程中的单例模式

class Singleton {
    private volatile static Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

这个虽然有synchronized关键字来保证单线程访问,但是这里面其实是instance=new Singleton()指令重排序的问题,这一步有三个原子性操作

  • 为instance分配内存
  • 调用构造参数初始化成员变量
  • 将instance对象指向分配好的内存空间
    其中第二步与第三步是会发生指令重排序的,这两部之间并没有数据依赖。如果第三步在第二步之前执行(此时instance已经非空了),此时另一个线程发现instance非null,就直接拿去使用了,这时第二步初始化变量操作还没有执行,就会发生错误。

synchronized

synchronized同样是java中的一个关键字。它通过锁机制实现同步,要执行代码,则必须要获得锁,只有获得锁对象的线程才能执行锁住的代码,其他线程没有获得锁只能阻塞。锁有对象锁类锁。同步有两种表现形式:同步代码块同步方法

  • 对象锁:仅针对该类的一个实例在不同线程中的应用,如果是同一个类的多个对象在不同的线程中,则不会有影响。
  • 类锁:针对该类的所有实例。

    同步代码块

  • 对象锁*
    class Test{
       public void testMethod(){
           synchronized(this){
               ...
           }
       }
    }
  • 类锁*
    class Test{
       public void testMethod(){
           synchronized(Test.class){
               ...
           }
       }
    }
  • 对象锁。这里的o代表任意一个object对象或者数组,谁拥有这个对象谁就可以执行该程序块代码*
    class Test{
       public void testMethod(){
           synchronized(o){
               ...
           }
       }
    }

    同步方法

  • 类锁*
    class Test{
       public synchronized static void testMethod(){
           ...
       }
    }
  • 对象锁*
    class Test{
       public synchronized void testMethod(){
           ...
       }
    }

ReentrantLock

ReentrantLock是一个类,它的同步方法与synchronized大致相同。

基本用法

ReentrantLock lock = new ReentrantLock(); //参数默认false,不公平锁
.....................
lock.lock(); //如果被其它资源锁定,会在此等待锁释放,达到暂停的效果
try {
    //操作
} finally {
    lock.unlock();  //释放锁
}

ReentrantLock通过lock方法与unlock方法显式的获取锁与释放锁,与synchronized隐式的获取锁不同。当线程执行到lock.lock()方法时,会尝试获取锁,获取到锁则执行下去,获取不到则会阻塞。unlock()方法则会释放当前线程所持有的锁,如果没有锁可以释放可能会发生异常。

显式的获取锁虽然比隐式的自动获取锁麻烦了不少,但多了许多可控制的情况。我们可以中断获取锁、延迟获取锁等一些操作。

公平锁

当许多线程在队列中等待锁时,cpu会随机挑选一个线程获得锁。这样就会出现饥饿现象,即优先级低的线程不断被优先级高的线程抢占锁资源,以至于很长时间获得不到锁,这就是不公平锁。RenntrantLock可以使用公平锁,即cpu调度按照线程先后等待的顺序获得锁,避免饥饿现象。但是执行效率会比较低,因为需要维护一个有序队列。synchronized是不公平锁。

ReentrantLock lock = new ReentrantLock(true);

通过在创建对象时传入boolean对象表示使用什么锁,默认为false不公平锁。

synchronized与reentrantLock比较

  • ReentrantLock实现Lock接口,是一个类,synchronized是java关键字,是内置的语言实现
  • synchron在发生异常时,会自动释放锁,几乎不会造成死锁。ReentrantLock需要手动的释放锁(unLock),所以在使用时应在finally代码块中释放锁。
  • ReentrantLock可以中断锁的获取,即获取不到锁时继续执行下去,而synchronized却不可以,会一直阻塞。
  • 通过lock可以知道有没有成功获取锁,synchronized不可以

可以看出,ReentrantLock实现了许多更高级的功能,不过却多了点复杂性。在性能上来说,竞争不激烈时,两者的性能是差不多的,不过当竞争激烈时,即有大量线程等待获取锁,ReentrantLock的性能要更好一些,具体的使用看情况进行。

jdk1.6以前synchronized的性能是很差的,jdk1.6以后对synchronized的性能优化了不少,和ReentrantLock性能差不了多少。官方也表示更支持synchronized,以后还有优化的余地,所以在都能符合需求的情况下,推荐使用synchronized。

CAS原子操作

乐观锁与悲观锁:

cpu调度线程,通过将时间片分配给不同的线程进行调度。时间片的切换也就是线程的切换,需要清除寄存器、缓存数据,切换后加载线程需要的数据,需要耗费一定的时间。线程阻塞后,通过notify、notifyAll唤醒。假如线程1在尝试获取锁,获取失败,挂起。这时锁被释放,线程1被唤醒,尝试获取锁,结果又被其他线程抢占锁,线程1继续挂起,获取锁的线程只占用锁很短的时间,释放锁,线程1又被唤醒。。。就这样,线程1反复的挂起、唤醒,线程1认为其他线程获取锁就一定会对锁内的资源进行更新等操作,所以不断等待,这就是悲观锁。synchronized这种独占锁就是悲观锁。

乐观锁并不加锁,首先会认为在自己修改资源之前其他线程不会对资源进行更新等操作,它会尝试用锁内资源进行自己的操作,如果修改后的数据发生冲突,就会放弃之前的操作。就这样一直循环,知道操作成功。

CAS就是一种乐观锁的概念,内有三个操作数—内存原值(C)、预期旧值(A)、新值(B),当且只当内存原值与预期旧值的结果一样时,才更新新值。不然就是不断地循环尝试。Java中java.util.concurrent.atomic包相关类就是 CAS的实现.

类名 说明
AtomicBoolean 可以用原子方式更新的 boolean 值。
AtomicInteger 可以用原子方式更新的 int 值。
AtomicIntegerArray 可以用原子方式更新其元素的 int 数组。
AtomicIntegerFieldUpdater 基于反射的实用工具,可以对指定类的指定 volatile int 字段进行原子更新。
AtomicLong 可以用原子方式更新的 long 值。
AtomicLongArray 可以用原子方式更新其元素的 long 数组。
AtomicLongFieldUpdater 基于反射的实用工具,可以对指定类的指定 volatile long 字段进行原子更新。
AtomicMarkableReference AtomicMarkableReference 维护带有标记位的对象引用,可以原子方式对其进行更新。
AtomicReference 可以用原子方式更新的对象引用。
AtomicReferenceArray 可以用原子方式更新其元素的对象引用数组。
AtomicReferenceFieldUpdater 基于反射的实用工具,可以对指定类的指定 volatile 字段进行原子更新。
AtomicStampedReference AtomicStampedReference 维护带有整数“标志”的对象引用,可以用原子方式对其进行更新。

这种不需要锁的非阻塞算法,在性能上是要优于阻塞算法。一般使用如下,实现自增i++的同步操作

public class Test {
    public AtomicInteger i;
    public void add() {
        i.getAndIncrement();
    }
}

CAS的问题

  • ABA问题。一个值从A变为B又变为A,它的值实际上是变化了,可是CAS却识别不出来。这种问题解决思路就是在变量前面加上版本号,即1A-2B-3A。从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
  • CAS操作如果长时间不成功,会给cpu带来巨大的开销。

   转载规则


《每日一道面试题(第6期)---如何实现多线程中的同步》 飞跃 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
每日一道面试题(第7期)---Android补间动画与属性动画的区别 每日一道面试题(第7期)---Android补间动画与属性动画的区别
零零碎碎的东西总是记不长久,仅仅学习别人的文章也只是他人咀嚼后留下的残渣。无意中发现了这个每日一道面试题,想了想如果只是简单地去思考,那么不仅会收效甚微,甚至难一点的题目自己可能都懒得去想,坚持不下来。所以不如把每一次的思考、理解以及别人
2020-03-31
下一篇 
每日一道面试题(第5期)---哪些情况下会导致OOM问题 每日一道面试题(第5期)---哪些情况下会导致OOM问题
零零碎碎的东西总是记不长久,仅仅学习别人的文章也只是他人咀嚼后留下的残渣。无意中发现了这个每日一道面试题,想了想如果只是简单地去思考,那么不仅会收效甚微,甚至难一点的题目自己可能都懒得去想,坚持不下来。所以不如把每一次的思考、理解以及别人
2020-03-31
  目录