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

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

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

并发编程

并发编程存在的意义

  • 资源利用率:程序的进行,无非就是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并无执行,就会形成问题。github

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

同步操做

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带来巨大的开销。
相关文章
相关标签/搜索