Java多线程技术总结:多线程基础、线程安全、CAS无锁、JDK并发包、并行设计模式、NIO和AIO、锁的优化
一、多线程基础
1、什么是线程
- 线程是进程内的执行单元
2、线程的基本操作
(1) 新建线程
1 | Thread t1=new Thread(); |
(2) 终止线程
- Thread.stop() 不推荐使用。它会释放所有monitor
(3) 中断线程
1 | public void Thread.interrupt() // 中断线程 |
(4) 线程的挂起和继续执行
- suspend()不会释放锁
- 如果加锁发生在resume()之前 ,则死锁发生
(5) 线程等待结束和谦让
- join()会等待线程结束后执行
- yeild会让出CPU的执行段
(6) 守护线程
- 在后台默默地完成一些系统性的服务,比如垃圾回收线程、JIT线程就可以理解为守护线程
- 当一个Java应用内,只有守护线程时,Java虚拟机就会自然退出
1 | Thread t=new DaemonT(); |
(7) 线程的优先级
1 | Thread high=new HightPriority(); |
高优先级的线程更容易再竞争中获胜
3、基本的线程同步操作
(1) synchronized
- 指定加锁对象:对给定对象加锁,进入同步代码前要获得给定对象的锁。
- 直接作用于实例方法:相当于对当前实例加锁,进入同步代码前要获得当前实例的锁。
- 直接作用于静态方法:相当于对当前类加锁,进入同步代码前要获得当前类的锁。
1 | // 指定加锁对象 |
(2) 线程的等待和唤醒
- Object.wait() 线程阻塞等待操作,操作会释放锁
- Obejct.notify() 唤醒等待的线程,操作会释放锁
- Obejct.notify()随机唤醒一个线程,Obejct.notifyAll()唤醒全部线程
二、Java内存模型和线程安全
1、原子性
原子性是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就 不会被其它线程干扰。
i++是原子操作吗? (不是)
2、有序性
在并发时,程序的执行可能就会出现乱序
一条指令的执行是可以分为很多步骤的
- 取指IF
- 译码和取寄存器操作数 ID
- 执行或者有效地址计算 EX
- 存储器访问 MEM
- 写回WB
3、可见性
可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。
volatile 修饰可以保证变量的可见性
4、Happen-Before规则
- 程序顺序原则:一个线程内保证语义的串行性
- volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性
- 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前
- 传递性:A先于B,B先于C,那么A必然先于C
- 线程的start()方法先于它的每一个动作
- 线程的所有操作先于线程的终结(Thread.join())
- 线程的中断(interrupt())先于被中断线程的代码
- 对象的构造函数执行结束先于finalize()方法
5、线程安全的概念
指某个函数、函数库在多线程环境中被调用时,能够正确地处理各个线程的局部变量,使程序功 能正确完成。
i++在多线程下访问的情况下是线程不安全的操作
最简单的保证线程安全的方法是添加synchronized关键字修饰
三、CAS无锁
1、什么是CAS
CAS算法的过程是这样:它包含3个参数CAS(V,E,N)。V表示要更新的变量,E表示预期值,N表示新值。仅当V 值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么 都不做。最后,CAS返回当前V的真实值。CAS操作是抱着乐观的态度进行的,它总是认为自己可以成功完成 操作。当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程 不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,CAS 操作即时没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。
2、无锁类的使用
(1) AtomicInteger
基于Integer的操作,利用CPU指令集修改对象的值,若修改之后的值不等于期望的值则一直重复操作,直至与期望的值相等位置
(2) Unsafe
非安全的操作,比如: 根据偏移量设置值 park() 底层的CAS操作
非公开API,在不同版本的JDK中, 可能有较大差异
1 | //获得给定对象偏移量上的int值 |
(3) AtomicReference
对引用进行修改 是一个模板类,抽象化了数据类型
(4) AtomicStampedReference
解决ABA问题,通过时间戳或版本号的比较判断是否被修改过
1 | //比较设置 参数依次为:期望值 写入新值 期望时间戳 新时间戳 |
(5) AtomicIntegerArray
支持无锁的数组
(6) AtomicIntegerFieldUpdater
让普通变量也享受原子操作
- Updater只能修改它可见范围内的变量。因为Updater使用反射得到这个变量。如果变量不可见,就会出错。
比如如果score申明为private,就是不可行的。 - 为了确保变量被正确的读取,它必须是volatile类型的。如果我们原有代码中未申明这个类型,那么简单得 申明一下就行,这不会引起什么问题。
四、JDK并发包
1、ReentrantLock
重入锁,是synchronized的高级版,老版本的JDK中synchronized性能优化较低,现在的版本中性能经过优化后与重入锁相当,所以需要高级功能的时候才需要用到重入锁
1 | public static ReentrantLock lock = new ReentrantLock(); |
- 可重入 单线程可以重复进入,但要重复退出
- 可中断 lockInterruptibly()
- 可限时 超时不能获得锁,就返回false,不会永久等待构成死锁
- 公平锁 先来先得
1 | public ReentrantLock(boolean fair) |
内部通过CAS实现线程是否拿到锁的判断,另外定义一个等待队列,将所有等待拿锁的线程给保存起来,进入等待的线程将进行park()操作将线程挂起,有锁释放了要执行的时候则调用unpark()操作
2、Condition
类似于 Object.wait()和Object.notify()
与ReentrantLock结合使用
1 | void await() throws InterruptedException; |
- await()方法会使当前线程等待,同时释放当前锁,当其他线程中使用signal()时或者signalAll()方法时,线
程会重新获得锁并继续执行。或者当线程被中断时,也能跳出等待。这和Object.wait()方法很相似。 - awaitUninterruptibly()方法与await()方法基本相同,但是它并不会再等待过程中响应中断。
- singal()方法用于唤醒一个在等待中的线程。相对的singalAll()方法会唤醒所有在等待中的线程。这和Obej ct.notify()方法很类似。
3、Semaphore
共享锁 运行多个线程同时临界区
设置多个线程能拿到锁的总个数,未拿到的线程则进行等待
1 | public void acquire() // 设置线程进入的限制 |
1 | public class MyTestC extends Thread { |
4、ReadWriteLock
ReadWriteLock是JDK5中提供的读写分离锁
- 读-读不互斥:读读之间不阻塞。
- 读-写互斥:读阻塞写,写也会阻塞读。
- 写-写互斥:写写阻塞。
1 | private static ReentrantReadWriteLock readWriteLock=new ReentrantReadWriteLock(); |
5、CountDownLatch
倒数计时器。
一种典型的场景就是火箭发射。在火箭发射前,为了保证万无一失,往往还要进行各项设备、仪器的检查。 只有等所有检查完毕后,引擎才能点火。这种场景就非常适合使用CountDownLatch。它可以使得点火线程 ,等待所有检查线程全部完工后,再执行
1 | static final CountDownLatch end = new CountDownLatch(10); |
6、CyclicBarrier
循环栅栏
Cyclic意为循环,也就是说这个计数器可以反复使用。比如,假设我们将计数器设置为10。那么凑齐第一批1 0个线程后,计数器就会归零,然后接着凑齐下一批10个线程
1 | public CyclicBarrier(int parties, Runnable barrierAction) |
7、LockSupport
提供线程阻塞原语
1 | LockSupport.park(); |
与suspend()比较不容易引起线程冻结
能够响应中断,但不抛出异常。中断响应的结果是,park()函数的返回,可以从Thread.interrupted()得到中断标志
8、并发容器
- HashMap
- ConcurrentHashMap
- Collections.synchronizedMap
- List
- synchronizedList
- Set
- synchronizedSet
- BlockingQueue,ConcurrentLinkedQueue阻塞队列
9、线程池
如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。
那么有没有一种办法使得线程可以复用,就是执行完一个任务,并不被销毁,而是可以继续执行其他的任务?在Java中可以通过线程池来达到这样的效果。
- newFixedThreadPool :创建一个指定工作线程数量的线程池。每当提交一个任务就创建一个工作线程,如果工作线程数量达到线程池初始的最大数,则将提交的任务存入到池队列中。
- newSingleThreadExecutor :创建一个单线程化的Executor,即只创建唯一的工作者线程来执行任务,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。如果这个线程异常结束,会有另一个取代它,保证顺序执行。单工作线程最大的特点是可保证顺序地执行各个任务,并且在任意给定的时间不会有多个线程是活动的。
- newCachedThreadPool :创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
- newScheduledThreadPool:创建一个定长的线程池,而且支持定时的以及周期性的任务执行,支持定时及周期性任务执行。
五、NIO和AIO
1、什么是NIO
NIO是New I/O的简称,与旧式的基于流的I/O方法相对,从名字看,它表示新的一套Java I/O标 准。它是在Java 1.4中被纳入到JDK中的,并具有以下特性:
- NIO是基于块(Block)的,它以块为基本单位处理数据
- 为所有的原始类型提供(Buffer)缓存支持
- 增加通道(Channel)对象,作为新的原始 I/O 抽象
- 支持锁和内存映射文件的文件访问接口
- 提供了基于Selector的异步网络I/O
2、buffer
NIO中的Buffer用于和NIO通道进行交互。数据是从通道读入缓冲区,从缓冲区写入到通道中的。
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。
使用Buffer读写数据一般遵循以下四个步骤:
- 写入数据到Buffer
- 调用flip()方法
- 从Buffer中读取数据
- 调用clear()方法或者compact()方法
当向buffer写入数据时,buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入到buffer的所有数据。
一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()或compact()方法。clear()方法会清空整个缓冲区。compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。
NIO利用buffer复制文件
1 | public static void nioCopyFile(String resource, String destination) throws IOException { |
Buffer中有3个重要的参数:位置(position)、容量(capactiy)和上限(limit)
capacity
- 作为一个内存块,Buffer有一个固定的大小值,也叫“capacity”.你只能往里写capacity个byte、long,char等类型。一旦Buffer满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据。
position
- 当你写数据到Buffer中时,position表示当前的位置。初始的position值为0.当一个byte、long等数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity – 1.
- 当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0. 当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。
limit
- 在写模式下,Buffer的limit表示你最多能往Buffer里写多少数据。 写模式下,limit等于Buffer的capacity。
- 当切换Buffer到读模式时, limit表示你最多能读到多少数据。因此,当切换Buffer到读模式时,limit会被设置成写模式下的position值。换句话说,你能读到之前写入的所有数据(limit被设置成已写数据的数量,这个值在写模式下就是position)
常用方法
- public final Buffer rewind()
- 将position置零,并清除标志位(mark)
- public final Buffer clear()
- 将position置零,同时将limit设置为capacity的大小,并清除了标志mark
- public final Buffer flip()
- 先将limit设置到position所在位置,然后将position置零,并清除标志位mark
- 通常在读写转换时使用
3、网络编程中的NIO
问题:
为每一个客户端使用一个线程,如果客户端出现延时等异常,线程可能会被占用很长时间。因为数据的准备和读取都在这个线程中。
此时,如果客户端数量众多,可能会消耗大量的系统资源
解决
- 非阻塞的NIO
- 数据准备好了在工作
4、网络编程中的AIO
- 读完了再通知我
- 不会加快IO,只是在读完后进行通知
- 使用回调函数,进行业务处理
六、锁的优化和注意事项
1、锁的优化思路和方法
- 减少锁持有时间
- 减小锁粒度
- 将大对象,拆成小对象,大大增加并行度,降低锁竞争
- 偏向锁,轻量级锁成功率提高
- ConcurrentHashMap
- HashMap的同步实现
- 锁分离
- 根据功能进行锁分离
- ReadWriteLock
- 读多写少的情况,可以提高性能
- 锁粗化
- 通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完 公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早的获得资源执行 任务。但是,凡事都有一个度,如果对同一个锁不停的进行请求、同步和释放,其本身也会消耗 系统宝贵的资源,反而不利于性能的优化
- 锁消除
- 在即时编译器时,如果发现不可能被共享的对象,则可以消除这些对象的锁操作
2、虚拟机内锁的优化
- 偏向锁
- 大部分情况是没有竞争的,所以可以通过偏向来提高性能
- 所谓的偏向,就是偏心,即锁会偏向于当前已经占有锁的线程
- 将对象头Mark的标记设置为偏向,并将线程ID写入对象头Mark
- 只要没有竞争,获得偏向锁的线程,在将来进入同步块,不需要做同步
- 当其他线程请求相同的锁时,偏向模式结束
- -XX:+UseBiasedLocking 默认启用
- 在竞争激烈的场合,偏向锁会增加系统负担
- 轻量级锁
- 普通的锁处理性能不够理想,轻量级锁是一种快速的锁定方法。
- 如果对象没有被锁定;将对象头的Mark指针保存到锁对象中;将对象头设置为指向锁的指针(在线程栈空间中)
- 如果轻量级锁失败,表示存在竞争,升级为重量级锁(常规锁)
- 在没有锁竞争的前提下,减少传统锁使用OS互斥量产生的性能损耗
- 在竞争激烈时,轻量级锁会多做很多额外操作,导致性能下降
- 自旋锁
- 当竞争存在时,如果线程可以很快获得锁,那么可以不在OS层挂起线程,让线程做几个空操作( 自旋)
- JDK1.6中-XX:+UseSpinning开启
- JDK1.7中,去掉此参数,改为内置实现
- 如果同步块很长,自旋失败,会降低系统性能
- 如果同步块很短,自旋成功,节省线程挂起切换时间,提升系统性能
总结:
- 不是Java语言层面的锁优化方法
内置于JVM中的获取锁的优化方法和获取锁的步骤
- 偏向锁可用会先尝试偏向锁
- 轻量级锁可用会先尝试轻量级锁
- 以上都失败,尝试自旋锁
- 再失败,尝试普通锁,使用OS互斥量在操作系统层挂起
七、ThreadLocal
1、什么是ThreadLocal
ThreadLocal很容易让人望文生义,想当然地认为是一个“本地线程”。其实,ThreadLocal并不是一个Thread,而是Thread的局部变量,也许把它命名为ThreadLocalVariable更容易让人理解一些。
- ThreadLocal.get: 获取ThreadLocal中当前线程共享变量的值。
- ThreadLocal.set: 设置ThreadLocal中当前线程共享变量的值。
- ThreadLocal.remove: 移除ThreadLocal中当前线程共享变量的值。
- ThreadLocal.initialValue: ThreadLocal没有被当前线程赋值时或当前线程刚调用remove方法后调用get方法,返回此方法值。
我们调用ThreadLocal.get等方法时,实际上是从当前线程中获取ThreadLocalMap
2、ThreadLocal的内存泄露
ThreadLocal 本身并不存储值,它只是作为一个 key 来让线程从 ThreadLocalMap 获取 value。ThreadLocalMap 是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收。
ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么系统 GC 的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏。
其实,ThreadLocalMap的设计中已经考虑到这种情况,也加上了一些防护措施:在ThreadLocal的get(),set(),remove()的时候都会清除线程ThreadLocalMap里所有key为null的value。
但是这些被动的预防措施并不能保证不会内存泄漏:
- 使用static的ThreadLocal,延长了ThreadLocal的生命周期,可能导致的内存泄漏(参考ThreadLocal 内存泄露的实例分析)。
- 分配使用了ThreadLocal又不再调用get(),set(),remove()方法,那么就会导致内存泄漏。
综合上面的分析,我们可以理解ThreadLocal内存泄漏的前因后果,那么怎么避免内存泄漏呢?
- 每次使用完ThreadLocal,都调用它的remove()方法,清除数据。