多线程三大特性
- 原子性:是指一个操作不可中断。但对于处理器,一个操作(比如b++)都会被解释成多条指令执行,没有同步限制的话,操作的原子性会破坏。
- 可见性:指的是一个线程修改的值,另外一个线程能及时看到。但是不管是处理器的高速缓存和主内存,还是JMM的工作内存与主内存,都会导致线程修改的同步延迟现象,无法保证可见性。
- 有序性:指程序有序执行。但是在编译器和处理器的重排序、多线程并发执行的环境下,一个看似顺序执行的代码在真实执行时都可能是乱序的。
重排序
- 编译器重排序
- 处理器指令重排序
如何解决:
- 原子性:乐观锁(CAS)和悲观锁(synchronized和ReentrantLock)
- 可见性:volatile、final、锁
- 有序性:volatile、final、锁
多线程编程的问题
优点:
资源利用率更好
程序设计在某些情况下更简单
程序响应更快
代价:
设计更复杂
上下文切换的开销
增加内存资源消耗
竞态和临界区
线程安全和共享资源
不可变性和只读的对象时线程安全的,比如String对象不可变所以是线程安全的
java的线程模型
程序实现线程主要有3种方式
- 依赖内核线程实现,由内核直接来调度线程,程序一般不会直接去使用内核线程(Kernel-Level Thread,KLT),而是调用内核的轻量级进程(Light Weight Process,LWP)接口,每个轻量级进程都由一个内核线程支持。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型。
- 使用用户线程实现,指的是用户线程完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。这种进程与用户线程之间1:N的关系称为一对多的线程模型。
- 使用用户线程加轻量级进程混合实现,在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M的关系。
java使用的就是第一种,依赖内核线程实现
java的线程调度:
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive Threads-Scheduling)。
-
协同式调度
如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以没有什么线程同步的问题。Lua语言中的“协同例程”就是这类实现。它的坏处也很明显:线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。很久以前的Windows 3.x系统就是使用协同式来实现多进程多任务的,相当不稳定,一个进程坚持不让出CPU执行时间就可能会导致整个系统崩溃。 -
抢占式调度
如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在Java中,Thread.yield()可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法的)。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java使用的线程调度方式就是抢占式调度。在JDK后续版本中有可能会提供协程(Coroutines)方式来进行多任务处理。与前面所说的Windows 3.x的例子相对,在Windows 9x/NT内核中就是使用抢占式来实现多进程的,当一个进程出了问题,我们还可以使用任务管理器把这个进程“杀掉”,而不至于导致系统崩溃。
java使用的是抢占式线程调度。
死锁和避免死锁
死锁的四个条件 :
1) 互斥条件:一个资源每次只能被一个进程使用。
2) 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
3) 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
4) 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
避免死锁的方法:
1)加锁顺序:比如银行转账要锁定两个账户,我们可以按照固定顺序,先锁定账号ID大的,再锁定账号ID小的,这样所有线程都按照同样的顺序加解锁就可以避免死锁。
2)加锁时限:比如使用显式锁的tryLock(long time)方法加锁,如果固定时间内无法获取锁,则返回false,不阻塞线程;
3)死锁检测 :比如使用显式锁的tryLock()方法,如果加锁失败,则释放已经持有的其它锁,再才重试。
4)避免一个线程同时获取多个锁,避免一个线程的锁内部同时占用多个资源。
锁死
锁死的情况是:比如,线程1锁定了对象A,同时调用wait()方法等待信号,线程2要先对对象A加锁才能给线程1发送notify信号唤醒线程1。
锁死跟死锁很像,区别在于:
- 死锁中,二个线程都在等待对方释放锁。
- 锁死中,线程1持有锁A,同时等待从线程2发来的信号,线程2需要锁A来发信号给线程1。
线程饥饿
在Java中,下面三个常见的原因会导致线程饥饿:
- 高优先级线程吞噬所有的低优先级线程的CPU时间。
- 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。
- 线程在等待一个本身(在其上调用wait())也处于永久等待完成的对象,因为其他线程总是被持续地获得唤醒。
解决问题,使用ReentranLock的公平锁。
悲观锁和乐观锁
概念区分
悲观锁:悲观的认为对于同一个数据的并发操作,一定是会发生修改的,因此对于同一个数据的并发操作采取加锁的形式。在Java中,各种锁(synchronized、ReentrantLock)基本是属于悲观的。
乐观锁:乐观的认为对于同一个数据的并发操作,是不会发生修改的,在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。
使用场景
悲观锁:线程阻塞的,并发时会引起线程上下文切换的,所以适合写比较多的场景。另外,在线程冲突多的情况下,自旋式的乐观锁(CAS)消耗cpu性能太严重,这时候更适合使用悲观锁(synchronized)。
乐观锁:不阻塞的,并发时会失败的线程会自旋式的重试,消耗cpu资源,所以适合读多写少、线程并发少的场景。
隐式锁(synchronizied)和显式锁(java.util.Lock)
概念区分
- 隐式锁,不需要显式进行加、解锁操作,只需使用synchronizied关键字修饰代码。然后synchronizied是内置的,底层实现机制是对象监控器,属于JVM级别的锁。
- 显式锁,必须要显式调用加锁和解锁操作方法。Lock是显式锁接口,具体的实现类有ReentrantLock、ReentrantReadWriteLock,底层实现是基于AQS同步器,属于接口级别的锁。
显式锁的优点:
- 支持响应线程中断,而synchronized无法中断一个等待获取锁的线程。
- 可定时等待锁
- 支持condition条件来await()/signal()线程,比synchronized的await()/notify()方法更加灵活,不局限于通知持有锁的对象,而且,Condition因为是一个等待队列,可以确保等待的线程能够按顺序被唤醒。
- 支持公平和不公平锁,而synchronized是不公平的。
- 支持共享锁和排他锁,而synchronized是排他的。
- 性能比synchronized好,java 6.0后,jvm对内置锁进行优化,差距变小了很多。
显式锁的问题:
- 必须要在finally块里手动释放锁,如果忘记的话会很危险,而synchronizied是自动加解锁;
- 在java 5时,使用Lock锁,JVM生成线程转储无法获取阻塞对象的信息,无法定位死锁等异常。从java 6开始,LockSupport才支持存储锁定对象,以便dump时获取锁定信息。
共享锁和互斥锁(排他锁)
互斥锁是独占式的,每次只能有一个线程占用资源。
共享锁是非独占式的。
synchronized和ReentrantLockd都是排他锁。
ReentrantReadWriteLock的写锁是一个支持可重入的排他锁。
ReentrantReadWriteLock的读锁是一个支持可重入的共享锁,允许多个线程共享,从而提高了线程的并发量。
ReentrantReadWriteLock也是使用同步器AQS实现的,特别的地方是,它使用一个共享int变量(32位),同时存储读和写两种状态(高16位表示读,低16位表示写)。
可重入锁
可重入性,意思就是一个线程可以对一个资源(对象)重复加锁。synchronized和ReentrantLockd都是可重入锁。
实现原理:每个锁关联一个线程持有者和一个计数器。当计数器为0时表示该锁没有被任何线程持有,那么任何线程都都可能获得该锁而调用相应方法。当一个线程请求锁成功后,会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。线程每次释放锁时,计数器会递减,直到第n次释放,计数器为0后才真正释放该锁,其它线程才有机会加锁。
公平锁和不公平锁
公平锁是按照fifo原则分配线程的,而不公平锁是抢占式的,谁抢到就是谁的。
公平锁是先来先得,但代价是不可避免的线程上下切换。
不公平锁因为不公平的竞争,可能造成有线程长久等待——“饥饿”,但减少了线程切换,保证更大的吞吐量。
默认情况下,使用不公平锁,除非有特别需要且锁的时间比较长,才考虑使用公平锁。
偏向锁、轻量级锁、自旋锁、重量级锁
这三种锁是指锁的状态,并且是针对Synchronized。
以前同步使用的锁都是重量级锁,Java 6为了减少获得锁和释放锁带来的性能消耗,引入“偏向锁”和“轻量级锁”,锁一共有4种状态,从低到高:无所状态、偏向锁状态、轻量级锁状态、重量级锁状态,这几种锁状态会随着竞争情况而升级。锁可以升级但不能降级,目的是为了提高获得和释放锁的效率。
- 偏向锁:是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
- 轻量级锁:是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
- 重量级锁:是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
对象头和同步块synchronized
在HotSpot JVM实现中,synchronized内置锁有个专门的名字:对象监视器。
synchronized的状态是通过对象监视器在对象头中的字段来表明的。
对象头:
对象头-Mark Wordsynchronized的用法:
image.png对象的wait和notify方法
对象的wait()、wait(long timeout)、notify()、notifyAll方法的使用注意事项:
- 线程必须在synchronize的语句块中调用wait或者notify方法
- 调用wait或者notify方法的对象必须时synchronize锁定的对象
- notify()不能保证唤醒哪个线程
- 不要对String对象或者全局对象调用wait方法,因为JVM/Compiler 在内部将常量的String变成相同的对象。
存在问题
- 过早唤醒:比如调用notifyAll()把满足条件和未没有满足条件的线程都唤醒;
- 信号丢失(Missed Signals):比如notify唤醒方法在wait()方法前被调用了,导致线程执行wait()后永远等待;
- 虚假唤醒(Spurious Wakeups):等待线程也有可能会在没有任何线程调用notify()的情况下被唤醒,这种现象是由于操作系统诡异而出现的;
有一种规范的写法,避免上面的过早唤醒和虚假唤醒问题:
synchronized(someobject){
while(保护条件不成立){
//暂停线程
someobject.wait();
}
//保护条件成立后,执行操作
dosomething();
}
synchronized(someobject){
//修改,使保护条件成立
updateSharedState();
//唤醒线程
someobject.notify();
}
锁优化
- 减少锁的时间
- 减少锁的粒度,LongAdder、ConcurrentHashMap、LinkedBlockingQueue都是将一个锁拆成多个锁,增加并行度
- 读写分离
- 锁粗化
- 使用CAS
线程状态
image.png image.pngDaemon守护线程
Thread.setDaemon(true)可以设置一个线程为守护线程。
守护线程是一种支持线程,当java虚拟机不存在主线程时,虚拟机将退出,守护线程将会终止。
所以,不能依赖守护线程finally块内的内容来确保执行关闭或清理资源。
线程中断
调用线程的interrupt()方法对其进行中断操作。
线程有两种响应中断的:
- 线程本身通过isInterrupted()方法判断是否被中断,为true表示被中断
- 部分java的方法,比如sleep()、Lock.await(),响应到中断,首先会清除中断标识(设为false),然后抛出InterruptedException异常,这时调isInterrupted()是false。
如何优雅地终止一个线程:
image.png上面的案例,通过自定义变量和中断标识判断的方式能够使线程终止时有机会去清理资源,而不是武断终止线程,这种做法显得更加安全和优雅。
线程通信
- volatile和synchronized,用于线程同步,保证变量访问的可见性和排他性。
- 等待/通知机制,wait()/notify系列方法,用于阻塞和唤醒线程
- 管道输入/输出流,PipeOutputStream、PipeInputStream、PipeReader、PipeWriter可以用于线程之间数据传输。
- Thread1.join()方法,意思是Thread1插入,当前线程先阻塞,等Thread1执行完才执行。join的背后实现机制其实是wait()/notifyAll。
- 过期的suspend()、resume()、stop(),可以暂停、恢复、停止线程,但是这几个方法调用后,线程不会释放资源(比如锁),所以已经被wait()/notify替代。
ThreadLocal
ThreadLocal线程变量,支持泛型,是一个以ThreadLocal对象为键、任意对象为值的存储结构。也叫线程副本,即ThreadLocal变量对每个线程都有一个独立的副本,不会互相影响。
ThreadLocal适合使用在方法调用耗时统计,可以跨方法,甚至跨类统计,因为它是以线程为统计路径的。
网友评论