并发编程基础
并发(concurrent)与并行 (Parallel)
并行:微观上同时执行,在同一时间点上,同时做多件事情。
并发:宏观上同时执行,多件事情在同一时间段内,交替执行。
Java 内存模型(JMM)
JMM 是Java内存模型( Java Memory Model)。它本身只是一个抽象的概念,并不真实存在,它描述的是一种规则或规范,是和多线程相关的一组规范。通过这组规范,定义了程序中对各个变量的访问方式。需要每个JVM 的实现都要遵守这样的规范。
JMM规定了所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存完成。
Java内存模型是围绕着并发编程中原子性、可见性、有序性这三个特征来建立的。
原子性:一个操作不能被打断,要么全部执行完毕,要么不执行。
可见性:一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量的这种修改(变化)
有序性:有序性可以总结为:在本线程内观察,所有的操作都是有序的;而在一个线程内观察另一个线程,所有操作都是无序的。
线程生命周期
五种基本状态
新建状态:当线程对象创建后,即进入了新建状态
就绪状态:当调用线程对象的start()方法,线程即进入了就绪状态
运行状态:当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态,开始执行run方法
阻塞状态:处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止运行,此时即进入阻塞状态。阻塞状态又分为:等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态;同步阻塞:线程在获取synchronize同步锁失败,它会进入同步阻塞状态;其他阻塞:线程执行sleep()、join()方法或io流阻塞
死亡状态:线程执行完了或者因异常退出了run()方法,该线程结束生命周期
线程常用方法
currentThread():静态方法,返回执行当前代码的线程
getName():获取当前线程的名字
setName():设置当前线程的名字
isAlive():判断当前线程是否存活
setPriority():设置线程的优先级别,线程优先级分10个等级;MAX_PRIORITY= 10;MIN_PRIORITY = 1;NORM_PRIORITY =5; 默认优先级为5
yield():主动释放当前线程的执行权,让当前线程从运行状态转为就绪状态,以允许具有相同优先级的其他线程获得运行机会,但是yield不会释放锁
join():在线程中插入执行另一个线程,该线程被阻塞,直到插入执行的线程完全执行完毕以后,该线程才继续执行下去,在线程a中调用线程b.join()方法,此时线程a就进入阻塞状态,直到线程b完全执行完以后,线程a才结束阻塞状态。当b.join的时候,父线程a只会释放了b也就是子线程对象这个锁,故 join()是否会释放锁,要看join的外层,synchronized作用的对象,是其他object实体对象,还是子线程b本身。
sleep():让出CPU,让线程休眠一段时间,等待期间不会释放锁资源。
线程&线程池
线程创建
1.继承Thread类,重写run()方法,调用start()方法启动线程;优点:编码简单;缺点:继承了Thread类无法继承其他类,线程执行结果不能直接返回
2.实现Runnable接口,重写run()方法,创建此类对象,将任务对象交给线程或线程池处理;优点:扩展性强可以实现接口和继承类;缺点:线程执行结果不能直接返回
3.实现Callable接口,重写call()方法,用FutureTask把Callable对象封装为任务对象,将任务对象交给线程或线程池处理;优点:扩展性强可以实现接口和继承类,可以用FutureTask的get方法获取线程执行结果
线程池创建
线程池:提供了一个线程队列,队列中保存着所有等待状态的线程。避免了创建与销毁额外开销,提高了响应的速度;
Executors,线程池工具类,通过不同方法返回不同类型的线程池对象,不推荐;
ThreaPoolExecutor,ExcuterService实现类,创建线程池方式构造函数,参数自己指定
execute()提交Runnable类型不需要返回值的任务,submit()既能提交Runnable类型不需要返回值的任务也能提交Callable类型需要返回值的任务;execute会直接抛出任务执行时的异常,可以用try、catch来捕获,和普通线程的处理方式完全一致。submit会吃掉异常,可通过Future的get方法将任务执行时的异常重新抛出。
线程同步
wait()¬ify()
当线程执行wait()方法时候,会释放当前的锁,然后让出CPU,进入等待状态。wait()使当前线程阻塞,前提是必须先获得锁,一般配合synchronized 关键字使用,即,一般在synchronized 同步代码块里使用 wait()、notify/notifyAll() 方法。
wait():作用是使当前线程从调用处中断并且释放锁转入等待队列,直到收到notify或者notifyAll的通知才能从等待队列转入锁池队列,没有收到停止会一直死等。
wait(long time):相比wait多了一个等待的时间time,如果经过time(毫秒)时间后没有收到notify或者notifyAll的通知,自动从等待队列转入锁池队列。
notify():随机从等待队列中通知一个持有相同锁的一个线程,如果没有持有相同锁的wait线程那么指令忽略无效。注意是持有相同锁,并且是随机没有固定的,顺序这一点在生产者消费者模型中很重要,会造成假死的状态。
notifyAll():通知等待队列中的持有相同锁的所有线程,让这些线程转入锁池队列。如果没有持有相同锁的wait线程那么指令忽略无效。
wait的两个方法都需要注意中断的问题,wait中断是从语句处中断并且释放锁,当再次获得锁时是从中断处继续向下执行。notify 和 notifyAll方法通知是延迟通知,必须等待当前线程体执行完所有的同步方法/代码块中的语句退出释放锁才通知wait线程。
synchronized
1. 使用synchronized关键字
修饰静态方法:锁对象是类的Class对象
修饰非静态方法:锁对象是this
修饰代码块:对某一段代码进行加锁控制,需要自己传入锁对象,并且要求锁对象是唯一的。
使用synchronized可以保证原子性(synchronized代码块内容要么不执行,要执行就保证全部执行完毕)和可见性。
JMM关于Synchronized的两条规定:
线程解锁前,必须把共享变量的最新值刷新到主内存中;
线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量是需要从主内存中重新读取最新的值(加锁与解锁需要统一把锁)。
某一个线程进入synchronized代码块前后,执行过程入如下:
1. 线程获得互斥锁
2. 清空工作内存
3. 从主内存拷贝共享变量最新的值到工作内存成为副本
4. 执行代码
5. 将修改后的副本的值刷新回主内存中
6. 线程释放锁
volatile
volatile变量每次被线程访问时,都强迫线程从主内存中重读该变量的最新值,而当该变量发生修改变化时,也会强迫线程将最新的值刷新回主内存。这样一来,不同的线程都能及时的看到该变量的最新值。volatile能保证有序性,禁止指令重排序。
但是volatile不能保证变量更改的原子性,因此多线程下的写复合操作会导致线程安全问题。
synchronized和volatile比较
volatile不需要同步操作,所以效率更高,不会阻塞线程,但是适用情况比较窄
synchronized既能保证共享变量可见性,也可以保证锁内操作的原子性;volatile只能保证可见性
countDownlatch
一、什么是countDownlatch
CountDownLatch是一个同步工具类,它通过一个计数器来实现的,初始值为线程的数量。每当一个线程完成了自己的任务,计数器的值就相应得减1。当计数器到达0时,表示所有的线程都已执行完毕,然后在等待的线程就可以恢复执行任务。
二、方法详解
CountDownLatch(int count):count为计数器的初始值(一般需要多少个线程执行,count就设为几)。
countDown(): 每调用一次计数器值-1,直到count被减为0,代表所有线程全部执行完毕。
getCount():获取当前计数器的值。
await(): 等待计数器变为0,即等待所有异步线程执行完毕。
boolean await(long timeout, TimeUnit unit): 此方法与await()区别:
①此方法至多会等待指定的时间,超时后会自动唤醒,若 timeout 小于等于零,则不会等待
②boolean 类型返回值:若计数器变为零了,则返回 true;若指定的等待时间过去了,则返回 false
三、CountDownLatch应用场景
- 某个线程需要在其他n个线程执行完毕后再向下执行
- 多个线程并行执行同一个任务,提高响应速度
主线程等待子线程
方法一:主线程sleep
主线程等待子线程执行完最简单的方式是在主线程中Sleep一段时间
方法二:子线程join
方法三:使用CountDownLatch
初始设置和子线程个数相同的计数器,子线程执行完毕后计数器减1,直到全部子线程执行完毕。注意countDownLatch不可能重新初始化或者修改CountDownLatch对象内部计数器的值。countDownLatch.await()方法会一直阻塞直到计数器为0,主线程才会继续往下执行。
(上面方法都有一个缺陷:在任务执行完毕之后无法获取执行结果,如果需要获取执行结果,就必须通过共享变量或者使用线程通信的方式来达到效果,使用起来比较麻烦。)
方法四:Feture、FetureTask和Callable
通过它们可以在任务执行完毕之后得到任务执行结果,执行FutureTask.get()方法时,会阻塞当前主线程,直至futureTask任务执行完成返回结果
锁分类
悲观锁和乐观锁
悲观锁:在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。
乐观锁:在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
实现方式:悲观锁的实现方式是加锁,加锁既可以是对代码块加锁(如Java的synchronized关键字),也可以是对数据加锁。synchronized关键字和Lock的实现类都是悲观锁。
乐观锁的实现方式主要有两种:CAS机制和版本号机制。乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。
公平锁和非公平锁
公平锁:是指多个线程按照申请锁的顺序来获取锁,类似于排队买饭,先来后到,先来先服务,就是公平的,也就是队列。
非公平锁:是指多个线程获取锁的顺序,并不是按照申请锁的顺序,有可能申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转,或者饥饿的线程(也就是某个线程一直得不到锁)
并发包中ReentrantLock的创建可以指定构造函数的boolean类型来得到公平锁或者非公平锁,默认是非公平锁。
可重入锁和非可重入锁
可重入锁:线程可以再次进入它已经拥有的锁的同步代码块。
非可重入锁:线程不可以再次进入它已经拥有的锁的同步代码块,会导致死锁。
synchronized和ReentrantLock都是可重入锁。在实现上,就是线程每次获取锁时判定如果获得锁的线程是它自己时,简单将计数器累积即可,每释放一次锁,进行计数器累减,直到计算器归零,表示线程已经彻底释放锁。底层则是利用了JUC中的AQS来实现的
共享锁和排它锁
排它锁:又称独占锁,独享锁 synchronized就是一个排它锁;
共享锁:又称为读锁,获得共享锁后,可以查看,但无法删除和修改数据,其他线程此时也可以获取到共享锁,也可以查看但是无法修改和删除数据。
共享锁和排它锁典型是ReentranReadWriteLock 其中,读锁是共享锁,写锁是排它锁。
AQS原理
是用来构建锁或者其他同步组件的基础框架,比如ReentrantLock、ReentrantReadWriteLock和CountDownLatch就是基于AQS实现的。它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。它是CLH队列锁的一种变体实现。它可以实现2种同步方式:独占式,共享式。
原子类型
jdk提供了java.util.concurrent.atomic包,这个包中的原子操作类,提供了一种用法简单,性能高效,线程安全的更新一个变量的方式,通过一种无锁且又能保证线程安全的方式来提高并发效率。结合 CAS 和 volatile 实现无锁并发,适用于线程数少、多核 CPU 的场景下。atomic包里面一共提供了13个类,分为4种类型,分别是:原子更新基本类型,原子更新数组,原子更新引用,原子更新属性。
atomic提供了3个类用于原子更新基本类型:分别是AtomicInteger原子更新整形,AtomicLong原子更新长整,AtomicBoolean原子更新bool值。
AtomicInteger的常用方法有:
int addAndGet(int delta):以原子的方式将输入的值与实例中的值相加,并把结果返回
boolean compareAndSet(int expect, int update):如果输入值等于预期值,以原子的方式将该值设置为输入的值
final int getAndIncrement():以原子的方式将当前值加1,并返回加1之前的值
void lazySet(int newValue):最终会设置成newValue,使用lazySet设置值后,可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
int getAndSet(int newValue):以原子的方式将当前值设置为newValue,并返回设置之前的旧值
concurrenthashmap&hashtable
HashTable:HashTable是线程安全的哈希表,但是我们观察底层源码会发现,HashTable保证线程安全无非就是给每个方法都用synchronized关键字进行加锁,这样我们的整个哈希表只有一把锁,当我们有十个线程竞争锁的时候,只要有一个线程的到锁,其他九个线程就需要阻塞等待,这会导致整个效率比较低效。HasTable 不仅给写操作加锁 put()、remove()、clone() 等,还给读操作加了锁 get()
ConcurrentHashMap:
- ConcurrentHashMap 没有大量使用 synchronsize 这种重量级锁。而是在一些关键位置使用乐观锁(CAS), 线程可以无阻塞的运行。
- 读方法没有加锁
- 扩容时老数据的转移是并发执行的,这样扩容的效率更高。
jdk1.7中ConcurrentHashMap是使用了锁分段技术来保证线程安全的。CocurrentHashMap 是由 Segment 数组和 HashEntry 数组和链表组成。通过把整个Map分为N个Segment,可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。Hashtable的synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术。有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。锁分段技术:首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
在jdk1.8中的实现抛弃了Segment分段锁机制,利用CAS+Synchronized来保证并发更新的安全,将锁的粒度进一步减小,从Segment级别变成Node级别,又提升了并发的效率。底层依然采用数组+链表+红黑树的存储结构(当链表上的节点数量超过一定阈值的时候,就会转换成红黑树)。
CAS
CAS的基本思路:如果这个地址上的值和期望的值相等,则给其赋予新值,否则不做任何事儿,但是要返回原值是多少。循环CAS就是在一个循环里不断的做cas操作,直到成功为止。
CAS的三大问题:
- ABA问题,可以使用版本号解决,对应AtomicMarkableReference和AtomicStampedReference;
- 循环时间长开销大;
- 只能保证一个共享变量的原子操作;
ThreadLocal
ThreadLocal是Java里一种特殊的变量。ThreadLocal为每个线程都提供了变量的副本,使得每个线程在某一时间訪问到的并非同一个对象,这样就隔离了多个线程对数据的数据共享。
在内部实现上,每个线程内部都有一个ThreadLocalMap,用来保存每个线程所拥有的变量副本。
synchronized&ReentrantLock
都是 Java 中提供的可重入锁,二者的主要区别有以下 5 个:
- 用法不同:synchronized 可以用来修饰普通方法、静态方法和代码块,而 ReentrantLock 只能用于代码块。
- 获取锁和释放锁的机制不同:synchronized 是自动加锁和释放锁的,而 ReentrantLock 需要手动加锁和释放锁。
- 锁类型不同:synchronized 是非公平锁,而 ReentrantLock 默认为非公平锁,也可以手动指定为公平锁。
- 响应中断不同:ReentrantLock 可以响应中断,解决死锁的问题,而 synchronized 不能响应中断。
- 底层实现不同:synchronized 是 JVM 层面通过监视器实现的,而 ReentrantLock 是基于 AQS 实现的。