并发编程

843 阅读20分钟

并行与并发

并发:在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行。指的是多个事情,在同一时间段内同时发生了。并发的多个任务之间是互相抢占资源的。

并行:当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行(Parallel)。指的是多个事情,在同一时间点上同时发生了。并行的多个任务之间是不互相抢占资源的。


start() 方法和 run() 方法

  • 执行 start()方法会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。

  • 执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结: 调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。


ThreadLocal

ThreadLocal即线程变量,ThreadLocal类用来提供线程内部的局部变量。这种变量在多线程环境下访问(通过get和set方法访问)时能保证各个线程的变量相对独立于其它线程内的变量。ThreadLocal的作用是:提供线程内的局部变量,不同的线程之间不会相互干扰,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或组件之间一些公共变量传递的复杂度。 特点: 传递数据:保存每个线程绑定的数据,在需要的地方可以直接获取,避免参数直接传递带来的代码耦合问题 线程隔离:各线程之间的数据相互隔离却又具备并发性,避免同步方式带来的性能损失 内部结构:

  • 每个Thread线程内部都有一个ThreadLocalMap的实例,叫threadLocals。
  • threadLocals里面存储的ThreadLocal对象(key)和线程变量(value)。
  • ThreadLocalMap是由ThreadLocal维护的,是内部类,由ThreadLocal负责向map获取和设置线程的变量值。

对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成了副本的隔离,互不干扰。

内存泄漏:

虽然ThreadLocal防止了其它线程对当前线程中线程变量的访问,但是它本身会存在内存泄漏的问题,因为: 其实不管是entry继承强引用还是弱引用,在没有手动删除这个Entry以及Current Thread 依然运行的前提下,都会导致内存泄漏问题,所以真正导致内存泄漏的原因是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏。

事实上,在ThreadLocalMap中的 set / getEntry方法中,会对key为null(也即是ThreadLocal为null)进行判断,如果为null的话,那么是会对value置为null的。这就意味着使用完ThreadLocal,CurrentThread依然运行的前提下,就算忘记调用remove方法,弱引用比强引用可以多一层保障:弱引用的ThreadLocal会被回收,对应的value在下一次ThreadLocalMap调用set,get,remove中的任一方法的时候会被清除,从而避免内存泄漏。

要避免内存泄漏有两种方式:

  • 使用完ThreadLocal,调用其remove方法删除对应的Entry.
  • 使用完ThreadLocal,当前Thread也随之运行结束.

ThreadLocalMap的set方法:

  • 首先还是根据key计算出索引,然后查找位置上的Entry,
  • 若是Entry已经存在并且key等于传入的key,那么这时候直接给这个Entry赋新的value值,
  • 若是Entry存在,但是key为null,则调用replaceStaleEntry来更换这个key为空的Entry,
  • 不断循环检测,直到遇到为null的地方,这时候要是还没在循环过程中return,那么就在这个null的位置新建一个Entry,并且插入,同时size增加1。

ThreadLocal与synchronized的区别

虽然ThreadLocal与synchronized关键字都用于处理多线程并发访问变量的问题,不过两者处理问题的角度和思路不同。

SynchronizedThreadLocal
原理以时间换空间,只提供一份变量,让不同的线程排队访问以空间换时间,每个线程提供个变量,变量线程私有
侧重点多个线程之间访问资源的同步多线程中让每个线程之间的数据相互隔离

虽然使用ThreadLocal和synchronized都能解决问题,但是使用ThreadLocal更为合适,因为这样可以使程序拥有更高的并发性。


synchronized与ReentrantLock锁对比

synchronized是JVM层面的锁,是通过依靠JVM来执行的,1.6之前性能较差,1.6之后,jdk优化了synchronized,所以性能好了很多。当竞争资源不激烈的时候,两者的性能是差不多的,而竞争激烈的时候,ReentrantLock的性能要比synchronized好。当然,在使用的时候还是要根据具体情况选择。

synchronized VS ReentrantLock:

  • synchronized加锁是需要JVM调用底层的OS来进行加锁的,这样就存在从 用户态 -> 内核态 进行切换的开销。因为ReentrantLock属于API层面,不需要进行资源的切换,也就是不用从 用户态 切换到 内核态。
  • synchronized不需要用户去手动释放锁,当synchronized代码执行完,系统会自动释放锁;ReentrantLock则需要用户去手动释放锁,若没有主动释放锁,就有可能出现死锁的现象。
  • ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情;而synchronized不可中断,除非抛出异常或者正常运行完成。
  • synchronized是非公平锁;而ReentrantLock可以指定为公平锁还是非公平锁,默认是非公平锁。
  • ReentrantLock可以配合Condition使用,实现wait()/notify()的功能。

synchronized锁升级过程(简述)

jdk1.6之后,优化了synchronized,添加了锁升级机制

  • 首先第一个线程访问同步代码块的时候将会尝试添加一个偏向锁,也就是对当前的线程有偏向的功能,即不进行复杂加锁校验等,在对象头中,记录了偏向的线程id。下次这个线程再次进入同步代码块的时候,看一下偏向id是否是自己的id,是的话,直接进入即可。
  • 但是假设线程正在访问的时候,又来一个线程来访问这个同步资源的时候,也就是出现了锁竞争的情况,偏向锁就会升级成轻量级锁,也就是我们所说的CAS自旋锁,会不断的通过自旋操作来获取锁。谁抢到锁就去访问代码块。
  • 当有两个以上的线程争抢同一个锁,那么就会升级成重量级锁。还有一种情况,如果某个线程长时间进行自旋操作,并且自旋超过了限定的次数仍然没有成功获取到锁,就会升级成重量级锁,当然JDK1.6引入了自适应的自旋锁。自旋的时间由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。总而言之,当某个线程长期获取不到资源的时候,就会升级成重量级锁,这个时候只要其它线程过来后,获取不到锁就会直接被挂起,阻塞。

AQS

抽象队列同步器,是一个用来构建锁和同步器的框架,JUC包下其它大多数组件的核心,例如:ReentrantLock、CountdownLatch、Semaphore、ReentrantReadWriteLock。 AQS的核心思想:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一个线程阻塞等待以及唤醒时锁分配的机制,这个AQS是用CLH锁队列实现的,即将暂时获取不到的锁的线程加入到队列中。(CLH队列是一个虚拟的双向队列,虚拟的双向队列即不存在队列的实例,仅存在节点之间的关联关系。AQS是将每一条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node),来实现锁的分配。)

(通俗的说就是,AQS基于CLH锁队列,用volatile修饰共享变量state,线程通过CAS去改变state值,修改成功则获取锁成功,失败则进入等待队列并等待被唤醒。) state就是共享资源,其访问方式有如下三种:

  • getState()
  • setState()
  • compareAndSetState()

AQS定义了两种资源共享方式:

  • Exclusive:独占,只有一个线程能执行,如ReentrantLock
  • Share:共享,多个线程可以同时执行,如Semaphore、CountDownLatch、ReentrantReadWriteLock、CycleBarrier

自定义同步器时需要重写下面几个AQS提供的模板方法:

isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但无剩余资源可用;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。

Java内存模型(JMM)

在Java虚拟机规范中定义了一种Java内存模型,目的是为了屏蔽各种硬件和操作系统之间的内存访问差异,使得java程序在各种平台上运行都能达到一致的内存访问效果。而java内存模型的主要目标就是定义程序中各个变量的访问规则,也就是JVM将变量存储到内存中和从内存中取变量的底层细节。 JMM定义了这样几个规则或规范:

  • 所有共享变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问。
  • 每个线程还存在自己的工作内存,工作内存是每个线程的私有数据区域,线程对共享变量的操作必须在工作内存中进行,首先要将共享变量从主内存拷贝到自己的工作内存空间,然后对共享变量进行操作,操作完成后再将共享变量写会主内存。不能直接操作主内存中的共享变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝。
  • 不同线程之间也不能直接访问对方工作内存的变量,线程间的通信必须通过主内存来完成。

举个例子:如果有两个线程A,B,内存中有个共享变量C,如果A和B要同时对C进行修改,那么过程是这样的,A先看自己线程的工作空间里有没有变量C,如果有,直接修改,如果没有,那么会去主内存中copy一份C的副本存到自己的工作内存中,对于B也是相同的操作。此时就有一个这样的问题,若是A先改好了把改完的C写到主存中,而B完全不知道A已经改了C,它改完后把C写到主存中,那么A的修改记录就被覆盖了。这是因为这样,才导致可见性问题的存在,这时候volatile就起作用了。


volatile

1. 是什么

volatile是比synchronized更轻量级的同步机制,大部分情况下,volatile的执行成本要比synchronized要低。volatile有以下两种特性

  • 保证共享变量的内存可见性;
  • 禁止指令重排序;

2. 内存可见性

被volatile修饰的共享变量,每次在操作该共享变量的时候都需要重新去主存中读取共享变量副本到工作内存中重新操作。形象点说就是当一个线程修改了volatile修饰的变量,当修改写回主存时,另外一个线程立即就能看到最新的值。

volatile可见性的实现就是借助了CPU的lock指令,通过在写volatile的机器指令前加上lock前缀,使写volatile具有以下两个原则

  • 被volatile修饰的共享变量被修改时,会立刻被同步到主内存中。
  • 被其修饰的共享变量在每次读取之前都从主内存刷新。

3. 禁止指令重排序

为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。而重排序一般可以分为如下三种:

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
  • 指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
  • 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。

如何禁止指令重排序: Java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。

volatile写操作 是在前面和后面分别插入内存屏障:

而volatile读操作 是在后面插入两个内存屏障:


synchronized

1. 是什么:

Jdk 1.6之前synchronized是一个重量级锁,每次加锁的过程都要和CPU内核打交道,性能较低,但是Jdk 1.6之后,为了减少锁的获取和释放所带来的性能消耗,引入了偏向锁轻量级锁,所以目前看来,synchronized的一共有四种锁状态:无锁-->偏向锁-->轻量级锁-->重量级锁。所以,对象一开始是无锁的状态,但随着线程竞争情况逐渐升级,锁也跟着一步步升级,直到重量级锁。但是这种锁升级的策略是不能有反向操作的,也就是没有降级的策略,一旦升级成重量级锁,就无法再退回到轻量级锁,这样做的原因也是因为提高锁的获取和释放的效率。

2. 锁升级的过程(详细)

在介绍锁升级的过程之前,得先介绍一下对象头的存储结构,因为synchronized锁信息是存储在java对象头里面的,注意的是数组对象头有12字节(3个字宽),非数组对象头有8个字节(2个字宽)。下面我们就来看看32位虚拟机下的非数组对象头存储结构:

长度内容说明
32bitMark Word存储对象的hashcode和一些锁信息
32bitClass Metadata Address对象类型数据的指针

(注:如果是数组对象,还会有32bit来存储数组的长度)

而我们主要关注的是Mark Word 中的信息,对于不同类型的锁,它记录的信息是不一样的:

锁升级过程:

上面的图详细描述了锁升级的过程,下面再详细的文字描述一下: 当我们创建一个对象时,锁标志位:01 ;是否为偏向锁:0;意思是当前对象是可偏向的,并且当前是无锁状态;

这时候有个线程A来访问同步代码块,首先先看看锁标志,标志位为01,这表示有可能是无锁状态,也有可能是偏向锁状态,先判断是否是偏向锁:标志位为0 -> 线程A直接通过CAS操作来把自己的线程id写到Mark Word里,同时修改偏向锁的标志位为1;如果标志位为1 -> 检查一下Mark Word中记录的是否是自己的线程id,不是的话,通过CAS替换线程id,是的话,直接获得偏向锁, 然后执行同步块即可。线程A访问完离开了,若是下次再来访问该同步代码块,那么可以下Mark Word里面是不是自己的线程id,如果是的话直接访问即可,因为此时同步代码块是偏向该线程的,没有其它线程来打扰,所以没有锁竞争

有一天,线程A正在访问该同步代码块,这时候线程B也来到了同步代码块,它也想访问,于是看看Mark Word,发现不是自己的线程id,于是通过CAS来获取偏向锁,因为此时线程A正在访问代码块,所以线程B自然是获取锁失败,此时竞争关系出现了,对象的偏向锁失效,此时应该将偏向锁撤销,具体过程如下:等到持有偏向锁的线程A到达安全点后,暂停线程A,检查线程A的线程状态,如果不处于活动状态,那么就释放线程A的偏向锁,并且唤醒线程A;如果处于活动状态,那么就将偏向锁升级为轻量级锁

将锁标志改为00,升级成轻量级锁之后,原持有偏向锁的线程A在自己的线程栈中分配锁记录,copy对象头的Mark Word到自己的锁记录中,然后将对象头的Mark Word的指向锁记录的指针指向线程A的锁记录,唤醒线程A,从安全点继续执行,执行完释放轻量级锁;而线程B也是一样的分配锁记录以及copy Mark Word到自己的锁记录中,通过CAS操作将对象头的Mark Word中的锁记录指针指向当前自己的锁记录,成功的话就获取到轻量级锁,可以执行同步代码块,若是失败的话,则一直自旋获取锁,重复尝试。

当然线程B也不可能一直这样自旋,若是自旋到达一定次数,CAS操作依然没有成功,为了避免无用的自旋,那么开始升级为重量级锁,更改锁标志位10,当处于重量级锁状态下,其它线程试图获取锁时都会被阻塞,当只有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的锁竞争。

3. 底层原理:

  • 当synchronized修饰方法时,JVM是通过加ACC_Synchronized标志来实现同步。
  • 而synchronized修饰代码块时,JVM是采用monitorentermonitorexit指令来实现同步的,进入代码块时,执行monitorenter指令,退出代码块时,执行monitorexit

volatile与synchronized的区别

  1. volatile只能修饰实例变量和类变量,而synchronized只能修饰方法和代码块

  2. volatile保证数据的可见性,但是不保证原子性(多线程进行写操作,不保证线程安全);而synchronized是一种排它(互斥)的机制,能保证线程安全。 volatile用于禁止指令重排序,可以解决单例双重检查对象初始化代码执行乱序问题。

  3. volatile是比synchronized更加轻量级的同步机制,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其它的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。


CountDownLatch、CyclicBarrier、Semaphore

CountDownLatch: 允许一个或多个线程等待其它线程完成操作。主要有countDown()方法和await()方法,CountDownLatch在初始化时,需要指定用给定一个整数作为计数器。当调用countDown()方法时,计数器会被减1;当调用await()方法时,如果计数器大于0时,线程会被阻塞,一直到计数器被countDown()方法减到0时,线程才会继续执行。计数器是无法重置的,(秦灭六国,一统华夏。做减法!)

CyclicBarrier:让一组线程到达一个屏障(同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,让被屏障阻塞的线程继续运行。线程进入屏障通过CyclicBarrier的await()方法。计数器可以重置。(集齐七龙珠,召唤神龙。做加法!)

Semaphore: 用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。两个目的:1)用于多个共享资源的互斥使用,2)用于并发线程数的控制。(争车位!)


ThreadPoolExecutor

线程池的核心实现类,主要由四个组件构成:

  • corePool:核心线程池的大小,
  • maximumPool:最大线程池的大小
  • BlockingQueue:用来暂时保存任务的阻塞队列。
    • ArrayBlockingQueue:一个基于数组的有界阻塞队列。
    • LinkedBlockingQueue:一个基于链表的无界阻塞队列。
    • PriorityBlockingQueue:一个具有优先级的无界阻塞队列。
    • SynchronousQueue:一个不存储元素的阻塞队列。
  • RejectedExecutionHandler:饱和策略,当队列和线程池都满了,说明线程池处于饱和状态,那么必须采取一种策略来处理提交新任务。
    • AbortPolicy:直接抛出异常
    • DiscardPolicy:不处理,丢弃。
    • DiscardOldestPolicy:丢弃队列里最前面的一个任务,并执行当前任务。
    • CallerRunsPolicy:主线程来执行任务。

img

几种典型的线程池:

  • FixedThreadPool:固定线程数的线程池,corePool和maximumPool一样大,阻塞队列为LinkedBlockingQueue。适用于处理CPU密集型的任务,确保CPU在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。
  • SingleThreadExecutor:单个线程的线程池,主要适用于保证顺序的执行各个任务。阻塞队列也为LinkedBlockingQueue。适用于串行执行任务场景。
  • CachedThreadPool:线程数可以无限的线程池,但是空闲线程超过60秒会被回收,阻塞队列为SynchronousQueue。意味着主线程提交任务的速度大于线程处理任务的速度时,线程池会不断的创建线程,极端情况下,会因为创建过多线程而耗尽CPU和内存资源。适合执行大量短生命周期任务