深入理解Java并发编程

CAS基本原理

什么是原子操作?

假定有两个操作A和B(A和B可能都很复杂),如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。

如何实现原子操作?

实现原子的操作可以使用加锁机制,满足基本需求是没有问题了,但是有的时候我们的需求并非这么简单,我们需要更加有效,更加灵活的机制,synchronized是基于阻塞的锁机制,也就是说当一个线程拥有锁的时候,访问同一资源的其他线程就处于阻塞状态,直到该线程释放锁。

那么这里会有一个问题:首先,如果被阻塞的线程优先级很高很重要怎么办?其次,如果获得锁的线程一直不释放锁怎么办?还有一种情况,如果有大量的线程来竞争资源,那CPU将会花费大量的时间和资源来处理这些竞争,同时,还有可能出现死锁之类的情况。

实现原子操作还可以使用当前的处理器基本都支持CAS()的指令,只不过每个厂家所实现的算法并不一样,每一个CAS操作过程都包含三个运算符:一个内存地址V,一个期望的值A和一个新值B,操作的时候如果这个地址上存放的值等于这个期望的值A,则将地址上的值赋为新值B,否则不做任何操作。

CAS的基本思路就是:当一个线程访问某一个变量时,首先将这个变量的值进行比较(期望是否为旧值),如果期望是旧值,就执行swap操作,将旧值交换为新值,如果期望的值与旧值不相等,就把整个CAS操作再次执行一次。

在这里插入图片描述

CAS实现原子操作的三大问题

  1. ABA问题

    因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。

    ABA问题的解决思路就是:使用版本号,在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。JDK提供了两个类AtomicMarkableReference(只关心变量有没有改过)、AtomicStampedReference(不但关心变量有没有改过,还关心被改过几次)

  2. 循环时间开销大

    因为在CAS操作中,线程不会休息,自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。

  3. 只能保证一个共享变量的原子操作

    当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。

    还有一个办法就是:我们把两个变量组合到一个对象里,直接改某个对象就行,一次性改过之后,用新对象直接替换老对象。

Jdk中相关原子操作类的使用

AtomicInteger
  • int addAndGet(int delta)

    以原子方式将输入的数值与实例中的值(AtomicInteger里的value)相加,并返回结果。

  • boolean compareAndSet(int expect, int update)

    如果输入的数值等于预期值,则以原子方式将该值设置为输入的值。

  • int getAndIncrement()

    以原子方式将当前值加1,注意,这里返回的是自增前的值。

  • int getAndSet(int newValue)

    以原子方式设置为newValue的值,并返回旧值。

AtomicIntegerArray

主要是提供原子的方式更新数组里的整型,其常用方法如下。

  • int addAndGet(int i, int delta)

    以原子方式将输入值与数组中索引i的元素相加。

  • boolean compareAndSet(int i, int expect, int update)

    如果当前值等于预期值,则以原子方式将数组位置i的元素设置成update值。

需要注意的是,数组value通过构造方法传递进去,然后AtomicIntegerArray会将当前数组复制一份,所以当AtomicIntegerArray对内部的数组元素进行修改时,不会影响传入的数组。

更新引用类型

原子更新基本类型的AtomicInteger,只能更新一个变量,如果要原子更新多个变量,就需要使用这个原子更新引用类型提供的类。Atomic包提供了以下3个类。

AtomicReference

原子更新引用类型。

AtomicStampedReference

利用版本戳的形式记录了每次改变以后的版本号,这样的话就不会存在ABA问题了。这就是AtomicStampedReference的解决方案。AtomicMarkableReference跟AtomicStampedReference差不多, AtomicStampedReference是使用pair的int stamp作为计数器使用,AtomicMarkableReference的pair使用的是boolean mark。 还是那个水的例子,AtomicStampedReference可能关心的是动过几次,AtomicMarkableReference关心的是有没有被人动过,方法都比较简单。

AtomicMarkableReference:

原子更新带有标记位的引用类型。可以原子更新一个布尔类型的标记位和引用类型。构造方法是AtomicMarkableReference(V initialRef, booleaninitialMark)

阻塞队列和线程池原理

阻塞队列

队列:

在这里插入图片描述

简单来说,就是一种**先进先出(FIFO—first in first out)**的数据结构。

什么是阻塞队列?

1.当队列满的时候,往队列里放数据的动作被阻塞
2.当队列空的时候,从队列里拿数据的动作被阻塞

在并发编程中使用生产者和消费者模式能够解决绝大多数并发问题。该模式通过平衡生产线程和消费线程的工作能力来提高程序整体处理数据的速度。

阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器。

在这里插入图片描述

抛出异常:当队列满时,如果再往队列里插入元素,会抛出IllegalStateException(“Queuefull”)异常。当队列空时,从队列里获取元素会抛出NoSuchElementException异常。

返回特殊值:当往队列插入元素时,会返回元素是否插入成功,成功返回true。如果是移除方法,则是从队列里取出一个元素,如果没有则返回null。

一直阻塞:当阻塞队列满时,如果生产者线程往队列里put元素,队列会一直阻塞生产者线程,直到队列可用或者响应中断退出。当队列空时,如果消费者线程从队列里take元素,队列会阻塞住消费者线程,直到队列不为空。

超时退出:当阻塞队列满时,如果生产者线程往队列里插入元素,队列会阻塞生产者线程一段时间,如果超过了指定的时间,生产者线程就会退出。

常用阻塞队列

  • ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。
  • LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列。
  • PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。
  • DelayQueue:一个使用优先级队列实现的无界阻塞队列。
  • SynchronousQueue:一个不存储元素的阻塞队列。
  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
  • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

以上的阻塞队列都实现了BlockingQueue接口,也都是线程安全的。

BlockingQueue里真正体现了阻塞的方法是put(E)take()

/**
     * Inserts the specified element into this queue, waiting if necessary
     * for space to become available.
     *
     * @param e the element to add
     * @throws InterruptedException if interrupted while waiting
     * @throws ClassCastException if the class of the specified element
     *         prevents it from being added to this queue
     * @throws NullPointerException if the specified element is null
     * @throws IllegalArgumentException if some property of the specified
     *         element prevents it from being added to this queue
     */
    void put(E e) throws InterruptedException;
/**
 * Retrieves and removes the head of this queue, waiting if necessary
 * until an element becomes available.
 *
 * @return the head of this queue
 * @throws InterruptedException if interrupted while waiting
 */
E take() throws InterruptedException;

有界无界?

有界队列就是长度有限,满了以后生产者会阻塞,无界队列就是里面能放无数的东西而不会因为队列长度限制被阻塞,当然空间限制来源于系统资源的限制,如果处理不及时,导致队列越来越大越来越大,超出一定的限制致使内存超限,操作系统或者JVM帮你解决烦恼,直接把你 OOM kill 省事了。

无界也会阻塞,为何?因为阻塞不仅仅体现在生产者放入元素时会阻塞,消费者拿取元素时,如果没有元素,同样也会阻塞。

线程池

为什么要用线程池?

Java中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序都可以使用线程池。在开发过程中,合理地使用线程池能够带来3个好处。

第一:降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。

第二:提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。假设一个服务器完成一项任务所需时间为:T1 创建线程时间,T2 在线程中执行任务的时间,T3 销毁线程时间。 如果:T1 + T3 远大于 T2,则可以采用线程池,以提高服务器性能。线程池技术正是关注如何缩短或调整T1,T3时间的技术,从而提高服务器程序性能的。它把T1,T3分别安排在服务器程序的启动和结束的时间段或者一些空闲的时间段,这样在服务器程序处理客户请求时,不会有T1,T3的开销了。

第三:提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。

ThreadPoolExecutor的类关系

Executor是一个接口,ExecutorService接口继承了Executor,在其上做了一些shutdown()submit()的扩展,可以说是真正的线程池接口;

AbstractExecutorService抽象类实现了ExecutorService接口中的大部分方法;

ThreadPoolExecutor是线程池的核心实现类,用来执行被提交的任务。

ScheduledExecutorService接口继承了ExecutorService接口,提供了带"周期执行"功能ExecutorService

ScheduledThreadPoolExecutor是一个实现类,可以在给定的延迟后运行命令,或者定期执行命令。ScheduledThreadPoolExecutorTimer更灵活,功能更强大。

线程池的创建

各个参数的含义

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {
    if (corePoolSize < 0 ||
        maximumPoolSize <= 0 ||
        maximumPoolSize < corePoolSize ||
        keepAliveTime < 0)
        throw new IllegalArgumentException();
    if (workQueue == null || threadFactory == null || handler == null)
        throw new NullPointerException();
    this.acc = System.getSecurityManager() == null ?
            null :
            AccessController.getContext();
    this.corePoolSize = corePoolSize;
    this.maximumPoolSize = maximumPoolSize;
    this.workQueue = workQueue;
    this.keepAliveTime = unit.toNanos(keepAliveTime);
    this.threadFactory = threadFactory;
    this.handler = handler;
}
corePoolSize

线程池中的核心线程数,当提交一个任务时,线程池创建一个新线程来执行任务,直到当前线程数等于corePoolSize

如果当前线程数为corePoolSize时,继续提交任务,则会被保存到阻塞队列中,等待被执行;

如果执行了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程。

maximumPoolSize

线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize

keepAliveTime

线程空闲时的存活时间,即当线程没有任务执行时,继续存活的时间。默认情况下,该参数只在线程数大于corePoolSize时才有用

TimeUnit

keepAliveTime的时间单位

workQueue

workQueue必须是BlockingQueue阻塞队列。当线程池中的线程数超过它的corePoolSize的时候,线程会进入阻塞队列进行阻塞等待。通过workQueue,线程池实现了阻塞功能。

一般来说,我们应该尽量使用有界队列,因为使用无界队列作为工作队列会对线程池带来如下影响。

1)当线程池中的线程数达到corePoolSize后,新任务将在无界队列中等待,因此线程池中的线程数不会超过corePoolSize

2)由于1,使用无界队列时maximumPoolSize将是一个无效参数。

3)由于1和2,使用无界队列时keepAliveTime将是一个无效参数。

4)更重要的,使用无界queue可能会耗尽系统资源,有界队列则有助于防止资源耗尽,同时即使使用有界队列,也要尽量控制队列的大小在一个合适的范围。

threadFactory

创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名,当然还可以更加自由的对线程做更多的设置,比如设置所有的线程为守护线程。

Executors静态工厂里默认的threadFactory,线程的命名规则是“pool-数字-thread-数字”。

RejectedExecutionHandler

线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:

(1)AbortPolicy:直接抛出异常,默认策略;

(2)CallerRunsPolicy:用调用者所在的线程来执行任务;

(3)DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;

(4)DiscardPolicy:直接丢弃任务;

当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务。

线程池的工作机制

在这里插入图片描述

(1)如果当前线程数小于corePoolSize,则创建新线程来执行任务。

(2)如果运行的线程等于或多于corePoolSize,则将新任务加入BlockingQueue

(3)如果无法将任务加入BlockingQueue(队列已满),则创建新的线程来处理任务。

(4)如果创建新线程将使当前运行的线程超出maximumPoolSize,任务将被拒绝,并调用RejectedExecutionHandler.rejectedExecution()方法。

提交任务

execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。

submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。

关闭线程池

可以通过调用线程池的shutdownshutdownNow方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止。但是它们存在一定的区别,shutdownNow首先将线程池的状态设置成STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表,而shutdown只是将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程

只要调用了这两个关闭方法中的任意一个,isShutdown方法就会返回true。当所有的任务都已关闭后,才表示线程池关闭成功,这时调用isTerminaed方法会返回true。至于应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用shutdown方法来关闭线程池,如果任务不一定要执行完,则可以调用shutdownNow方法。

合理地配置线程池

要想合理地配置线程池,就必须首先分析任务特性,可以从以下几个角度来分析。

  • 任务的性质:CPU密集型任务、IO密集型任务和混合型任务。

  • 任务的优先级:高、中和低。

  • 任务的执行时间:长、中和短。

  • 任务的依赖性:是否依赖其他系统资源,如数据库连接。

性质不同的任务可以用不同规模的线程池分开处理。

CPU密集型任务应配置尽可能小的线程,如配置Ncpu+1个线程的线程池。由于IO密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如2*Ncpu。

混合型的任务,如果可以拆分,将其拆分成一个CPU密集型任务和一个IO密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐量将高于串行执行的吞吐量。如果这两个任务执行时间相差太大,则没必要进行分解。可以通过Runtime.getRuntime().availableProcessors()方法获得当前设备的CPU个数。

优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理。它可以让优先级高的任务先执行。

执行时间不同的任务可以交给不同规模的线程池来处理,或者可以使用优先级队列,让执行时间短的任务先执行。

建议使用有界队列。有界队列能增加系统的稳定性和预警能力,可以根据需要设大一点儿,比如几千。

如果当时我们设置成无界队列,那么线程池的队列就会越来越多,有可能会撑满内存,导致整个系统不可用,而不只是后台任务出现问题。

AbstractQueuedSynchronizer

学习AQS的必要性

队列同步器AbstractQueuedSynchronizer(AQS)是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置FIFO队列来完成资源获取线程的排队工作。

AQS使用方式和其中的设计模式

AQS主要使用方式是继承,子类通过继承AQS并实现它的抽象方法来管理同步状态,在AQS里由一个int型的state来代表这个状态,在抽象方法的实现过程中免不了要对同步状态进行更改,这是就需要使用同步器提供了3个方法(getState()、setState(int newState)、compareAndSetState(int expect, int update))来进行操作,因为它们能够保证状态的改变是安全的。
在这里插入图片描述
在这里插入图片描述在这里插入图片描述
在这里插入图片描述
在实现上,子类推荐被定义为自定义同步父组件的静态内部类。

模板方法模式

同步器的设计基于模板方法模式。模板方法模式的意图是,定义一个操作中的算法的骨架,而将一些步骤的实现延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。我们最常见的就是Spring框架里的各种Template。

实际例子

我们开了个蛋糕店,蛋糕店不能只卖一种蛋糕呀,于是我们决定先卖奶油蛋糕,芝士蛋糕和水果蛋糕。三种蛋糕在制作方式上一样,都包括造型、涂抹和烘培方式,所以我们可以定义一个抽象蛋糕模型。

public abstract class AbstractCake {
    public abstract void shape();
    public abstract void apply();
    public abstract void brake();

    /*模板方法*/
    public final void run() {
        this.shape();
        this.apply();
        this.brake();
    }
}

然后就可以批量生产三种蛋糕

public class CheeseCake extends AbstractCake {
    @Override
    public void shape() {
        System.out.println("这是芝士蛋糕造型");
    }

    @Override
    public void apply() {
        System.out.println("这是芝士蛋糕涂抹");
    }

    @Override
    public void brake() {
        System.out.println("这是芝士蛋糕烘培");
    }
}
public class MakeCake {
    public static void main(String[] args) {

        AbstractCake cake = new CheeseCake();
        //AbstractCake cake = new FruitCake();
        //AbstractCake cake = new CreamCake();
        cake.run();
    }
}

这样一来,不但可以批量生产三种蛋糕,而且如果日后有扩展,只需要继承抽象蛋糕方法就可以了,十分方便,我们天天生意做得越来越赚钱。突然有一天,我们发现市面有一种最简单的小蛋糕销量很好,这种蛋糕就是简单烘烤成型就可以卖,并不需要涂抹什么食材,由于制作简单销售量大,这个品种也很赚钱,于是我们也想要生产这种蛋糕。但是我们发现了一个问题,抽象蛋糕是定义了抽象的涂抹方法的,也就是说扩展的这种蛋糕是必须要实现涂抹方法,这就很鸡儿蛋疼了。怎么办?我们可以将原来的模板修改为带钩子的模板。

/*模板方法*/
public final void run() {
    this.shape();
    if (this.shouldApply()) {
        this.apply();
    }
    this.brake();
}

做小蛋糕的时候通过flag来控制是否涂抹,其余已有的蛋糕制作不需要任何修改可以照常进行。

public class SmallCake extends AbstractCake {

    private boolean flag = false;
    
    public void setFlag(boolean flag) {
        this.flag = flag;
    }
    
    @Override
    protected boolean shouldApply() {
        return this.flag;
    }

    @Override
    public void shape() {
        System.out.println("这是小蛋糕造型");
    }

    @Override
    public void apply() {
        System.out.println("这是小蛋糕涂抹");
    }

    @Override
    public void brake() {
        System.out.println("这是小蛋糕烘培");
    }
}

AQS的实现原理

AQS的内部本质上是一个CLH队列锁,CLH队列锁是基于链表的自旋锁,每一个需要拿锁的线程打包成一个节点,然后挂到链表尾巴上去,凡是要拿锁的线程,都在链表上一一挂起来,每个线程会不断检测它的前驱节点的线程是否释放锁,如果释放锁,当前线程就可以拿到这把锁。

模板方法

实现自定义同步组件时,将会调用同步器提供的模板方法。
在这里插入图片描述

可重写的方法

在这里插入图片描述在这里插入图片描述

访问或修改同步状态的方法

重写同步器指定的方法时,需要使用同步器提供的如下3个方法来访问或修改同步状态。

  • getState():获取当前同步状态。
  • setState(int newState):设置当前同步状态。
  • compareAndSetState(int expect,int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性。
CLH队列锁

CLH队列锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程仅仅在本地变量上自旋,它不断轮询前驱的状态,假设发现前驱释放了锁就结束自旋。

当一个线程需要获取锁时:

1.创建一个的QNode,将其中的locked设置为true表示需要获取锁,myPred表示对其前驱节点的引用。
在这里插入图片描述
2.线程A对tail域调用getAndSet方法,使自己成为队列的尾部,同时获取一个指向其前驱节点的引用myPred
在这里插入图片描述
线程B需要获得锁,同样的流程再来一遍
在这里插入图片描述
3.线程就在前驱结点的locked字段上旋转,直到前驱结点释放锁(前驱节点的锁值 locked == false)

4.当一个线程需要释放锁时,将当前结点的locked域设置为false,同时回收前驱结点
在这里插入图片描述
如上图所示,前驱结点释放锁,线程A的myPred所指向的前驱结点的locked字段变为false,线程A就可以获取到锁。

CLH队列锁的优点是空间复杂度低(如果有n个线程,L个锁,每个线程每次只获取一个锁,那么需要的存储空间是O(L+n),n个线程有n个myNode,L个锁有L个tail)。CLH队列锁常用在SMP体系结构下。

Java中的AQS是CLH队列锁的一种变体实现。

ReentrantLock的实现

锁的可重入

重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞,该特性的实现需要解决以下两个问题。

1)线程再次获取锁。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。

2)锁的最终释放。线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经成功释放。

nonfairTryAcquire方法增加了再次获取同步状态的处理逻辑:通过判断当前线程是否为获取锁的线程来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回true,表示获取同步状态成功。同步状态表示锁被一个线程重复获取的次数。

如果该锁被获取了n次,那么前(n-1)次tryRelease(int releases)方法必须返回false,而只有同步状态完全释放了,才能返回true。可以看到,该方法将同步状态是否为0作为最终释放的条件,当同步状态为0时,将占有线程设置为null,并返回true,表示释放成功。

公平和非公平锁

ReentrantLock的构造函数中,默认的无参构造函数将会把Sync对象创建为NonfairSync对象,这是一个“非公平锁”;而另一个构造函数ReentrantLock(boolean fair)传入参数为true时将会把Sync对象创建为“公平锁”FairSync。

nonfairTryAcquire(int acquires)方法,对于非公平锁,只要CAS设置同步状态成功,则表示当前线程获取了锁,而公平锁则不同。tryAcquire方法,该方法与nonfairTryAcquire(int acquires)比较,唯一不同的位置为判断条件多了hasQueuedPredecessors()方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释放锁之后才能继续获取锁。

Java内存模型(JMM)

JMM基础-计算机原理

Java内存模型即Java Memory Model,简称JMM。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。Java1.5版本对其进行了重构,现在的Java仍沿用了Java1.5的版本。Jmm遇到的问题与现代计算机中遇到的问题是差不多的。

物理计算机中的并发问题,物理机遇到的并发问题与虚拟机中的情况有不少相似之处,物理机对并发的处理方案对于虚拟机的实现也有相当大的参考意义。

根据《Jeff Dean在Google全体工程大会的报告》我们可以看到
在这里插入图片描述
计算机在做一些我们平时的基本操作时,需要的响应时间是不一样的。

(以下案例仅做说明,并不代表真实情况。)

如果从内存中读取1M的int型数据由CPU进行累加,耗时要多久?

做个简单的计算,1M的数据,Java里int型为32位,4个字节,共有1024*1024/4 = 262144个整数 ,则CPU 计算耗时:262144 0.6 = 157 286 纳秒,而我们知道从内存读取1M数据需要250000纳秒,两者虽然有差距(当然这个差距并不小,十万纳秒的时间足够CPU执行将近二十万条指令了),但是还在一个数量级上。但是,没有任何缓存机制的情况下,意味着每个数都需要从内存中读取,这样加上CPU读取一次内存需要100纳秒,262144个整数从内存读取到CPU加上计算时间一共需要262144100+250000 = 26 464 400 纳秒,这就存在着数量级上的差异了。

而且现实情况中绝大多数的运算任务都不可能只靠处理器“计算”就能完成,处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个I/O操作是基本上是无法消除的(无法仅靠寄存器来完成所有运算任务)。早期计算机中cpu和内存的速度是差不多的,但在现代计算机中,cpu的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
在这里插入图片描述
在这里插入图片描述
在计算机系统中,寄存器划是L0级缓存,接着依次是L1,L2,L3(接下来是内存,本地磁盘,远程存储)。越往上的缓存存储空间越小,速度越快,成本也更高;越往下的存储空间越大,速度更慢,成本也更低。从上至下,每一层都可以看做是更下一层的缓存,即:L0寄存器是L1一级缓存的缓存,L1是L2的缓存,依次类推;每一层的数据都是来至它的下一层,所以每一层的数据是下一层的数据的子集。
在这里插入图片描述
在现代CPU上,一般来说L0, L1,L2,L3都集成在CPU内部,而L1还分为一级数据缓存(Data Cache,D-Cache,L1d)和一级指令缓存(Instruction Cache,I-Cache,L1i),分别用于存放数据和执行数据的指令解码。每个核心拥有独立的运算处理单元、控制器、寄存器、L1、L2缓存,然后一个CPU的多个核心共享最后一层CPU缓存L3。

Java内存模型(JMM)

CPU在运算指令和读取内存速度方面有很大差异,所以在现代CPU里面,引入了高速缓存机制,为了充分利用高速缓冲,java内存模型引入了工作内存和主内存,所有的线程不能对主内存进行直接操作,把内存的变量弄成副本放到工作内存中进行操作。

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
在这里插入图片描述
在这里插入图片描述

可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

由于线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,那么对于共享变量V,它们首先是在自己的工作内存,之后再同步到主内存。可是并不会及时的刷到主存中,而是会有一定时间差。很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了 。

要解决共享对象可见性这个问题,我们可以使用volatile关键字或者是加锁。

原子性

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

我们都知道CPU资源的分配都是以线程为单位的,并且是分时调用,操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片”。而任务的切换大多数是在时间片段结束以后,

那么线程切换为什么会带来bug呢?因为操作系统做任务切换,可以发生在任何一条CPU 指令执行完!注意,是 CPU 指令,CPU 指令,CPU 指令,而不是高级语言里的一条语句。比如count++,在java里就是一句话,但高级语言里一条语句往往需要多条 CPU 指令完成。其实count++包含了三个CPU指令!

JMM导致的并发安全问题

例如:count = count + 1;真的就只是一条语句?

我们在代码里写下的时候,其实就是一句话,一行代码,但是JAVA在执行的时候那就绝对不是一句话的事情了。

比如:现在我们有两个线程A和B,都要执行count + 1这个操作,刚开始count放在主内存count = 0;线程A要执行这个操作,于是把count读到自己的工作内存,在线程A的内部有了count的副本count = 0,线程A在执行的同时,线程B也要把count读到自己的工作内存,也等于0,接下来线程A和线程B执行count + 1操作,当两个线程都执行完的时候,线程A的内部的count变为1,线程B中的count也变为1,然后把count写回到主内存,线程A和线程都要往回写,A和B都把count加了1次,理论上讲,count的值应该为2,但在java内存模型里面,count的最终的值完全有可能是1,这就产生了线程的不安全问题。
在这里插入图片描述
下面用代码来展现这个问题:

/**
 * 类说明:简单的程序会有线程安全问题吗?
 */
public class SimplOper {

    private volatile long count =0;//计数器

   //count进行累加
    public void incCount() {
        count = count + 1;//count++;
    }

    //进行累加的线程
    private static class Count extends Thread{

        private SimplOper simplOper;

        public Count(SimplOper simplOper) {
            this.simplOper = simplOper;
        }

        @Override
        public void run() {
            for(int i=0;i<10000;i++){
                simplOper.incCount();//加10000
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        SimplOper simplOper = new SimplOper();
        //启动两个线程
        Count count1 = new Count(simplOper);
        Count count2 = new Count(simplOper);
        count1.start();
        count2.start();
        Thread.sleep(50);
        System.out.println(simplOper.count);//=20000?
    }
}
D:\Android-tools\Java\jdk-1.8.0_251\bin\java.exe  ...
18959

Process finished with exit code 0

可以看到输出的结果并不是20000,而且每次的结果不一样。一段很确定的程序得出不确定的结果,那这段程序就没什么用了。

再回过头看我们的分析,说明线程A和B互不知道对方对count的值做了修改,对方是不可见的,意思就是说我线程A改了count的值,而你线程B并不知道我改了count的值,也就是说线程A和线程B在工作过程中存在可见性问题。如何解决这个问题呢?

(1)使用volatile关键字

(2)加锁

首先使用volatile:我们发现,加上volatile关键字后,算出来的结果还是不正确。volatile只是强制线程从主内存读取一次变量,每当修改了变量的值时,再强制刷新回主内存。问题在于我们的计算过程不是一次就能搞定的,也就是这个操作不是一个原子操作,如果线程A对count的值加1后,很及时地刷新回主内存,线程B再执行这个操作时此时count的值为1,因为不是原子操作,也就意味着这个操作可以被打断(比如上下文切换),假设B算到一半的时候,操作被打断,线程A没有打断,一直在那里算下一次count + 1,count变成2,这时线程B被操作系统切回来,问题就出来了,线程B在做count + 1的操作时,是以1进行累加的,现在我被切回来后,发现count已经变成2了,因为线程B没有执行完这个操作,所以就不会再重新读取主内存,所以算出来的结果还是不正确的。

这次我们使用synchronized进行加锁,发现这次计算结果就没问题了。

public synchronized void incCount() {
    count = count + 1;//count++;
}
D:\Android-tools\Java\jdk-1.8.0_251\bin\java.exe  ...
20000

Process finished with exit code 0

volatile详解

可以把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步在这里插入图片描述
在这里插入图片描述
volatile变量自身具有下列特性:

  • 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

volatile的实现原理

说穿了利用CPU提供了Lock前缀指令
(1)将当前处理器缓存行的数据写回到系统内存
(2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效

synchroized实现原理

使用monitorenter和monitorexit指令实现
(1)monitorenter指令是在编译后插入到同步代码块的开始位置,monitorexit是插入到方法的结束位置。
(2)每个monitorenter必须有对应的monitorexit配对
(3)任何对象都有一个monitor与之关联

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值