Java多线程

Featured image

1. 线程的生命周期

新建状态
比如创建一个Thread或者其子类的对象,就是创建了一个线程,此时的线程处于新生状态(或者叫新建状态)

就绪状态
就绪状态是指线程已经具备了运行条件,但是没有分配到CPU计算的时间片(有点万事俱备只差东风的意思),并没有开始运行;
此时线程处在可运行队列中(不过CPU的调度可能并不是按照先进先出的顺序)。
java语言通过调用thread对象的start方法,将一个线程由新建状态转换为就绪状态。

运行状态
就绪线程获得CPU分配的时间片之后,由就绪状态变为运行状态。此时线程才开始真正执行。

阻塞状态
线程由于某种原因主动让出CPU,并暂停自己的运行;此时的状态为阻塞状态,阻塞状态一般有以下原因:
比如调用了sleep方法,wait方法;
等待IO设备、其他资源或者其他线程的状态完成等等(如:需要使用的资源被其他线程锁定)
进入阻塞状态的线程需要等待阻塞原因消除之后,才可以转换为就绪状态,重新进入就绪队列中排队等待CPU的时间片。

死亡状态
线程的run方法执行完毕,或者使用其他方式将线程杀死(比如调用stop或者destroy方法),线程就进入死亡状态;进入死亡状态的线程也许会占用一定的内存空间(线程对象可能还是活的),但是已经不是一个可以单独执行的线程,也不可以回到就绪状态(此时调用start方法会抛异常)

状态之间的转换

新建状态 -> 转换为就绪状态;
就绪状态 -> 运行状态;
运行状态 -> 阻塞状态(比如:sleep,wait,调用阻塞IO方法,等待锁资源,suspend方法等)
阻塞状态 -> 就绪状态
运行状态 -> 死亡状态
运行状态 -> 就绪状态

具体到java语言中有如下几种状态:
NEW
RUNNABLE(就绪状态,但未必在运行)
BLOCKED(等待锁)
WAITING(调用wait()、join()、park()之后的状态)
TIMED_WAITING(调用wait(long)、join(long)、parkUntil、parkNanos之后)
TERMINATED 已经完成执行

2. 可见性、原子性、有序性问题(并发编程bug的源头)

3. volatile关键字

4. threadLocal源码解读

第一个关键方法:withInitial,指定创建默认变量的方式,但get的时候,如果对象不存在,就通过这个方式创建对象。

第二个关键方法:getMap方法,了解此方法之前,需要知道 每个Thread对象都持有一个ThreadLocal.ThreadLocalMap 对象,将这个对象理解为一个容器即可,并且基于弱引用实现,有利于避免内存泄漏,但并不能完全避免。 getMap方法就是返回一个线程的ThreadLocal.ThreadLocalMap对象。

第二个关键方法:get方法获取真正的变量,且看源码:

    public T get() {
        Thread t = Thread.currentThread(); //获取当前线程
        ThreadLocalMap map = getMap(t); //获取当前线程的ThreadLocalMap
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this); //获取对象
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue(); //如果获取不到对象,使用默认初始化方式来初始化对象
    }

第四个关键方法:setInitialValue方法,大体逻辑就是,首先根据默认创建对象的方式,创建一个对象,如果线程的ThreadLocalMap属性不存在,就创建,并将默认对象放入创建的Map中;如果ThreadLocalMap存在,则直接放入。

本质上来讲,每个线程对应一个ThreadLocalMap容器,ThreadLocal变量的get方法是从当前线程的ThreadLocalMap中获取对象,当前线程的threadLocalMap对象是默认是不会给别的线程用的。

应用场景
解决某个变量多线程使用时,有并发问题的场景;通过每个线程对应单独的一个变量来解决。

SimpleDataformat是常用的一个时间处理类,使用时往往创建一个实例,然后调用这个实例的方法去做时间处理。
但是实例对象并不是线程安全的,多线程使用会有并发问题。如果每次使用的时候,都重新创建一个实例对象又比较耗时,耗资源。
使用ThreadLocal来实现每个线程持有一个对象,这样既不会有太多的资源浪费,又能解决并发问题。
在使用的时候,通过调用local的get方法,获取SimpleDateFormat实例对象,然后使用。
这样就能保证安全的使用SimpleDateFormat

5. Semaphore

6. CorrentHashMap的实现(JDK7 与 8有区别)

7. Thread 类源码分析

wait()
使当前线程等待,直到其他线程调用该对象的notify()或者notifyAll()方法;,该方法是native实现的
调用该方法的线程,必须拥有该对象的锁;否则会抛异常。调用此方法之后,当前线程会让出该对象的锁。
变种:可以指定等待时间。

notify()/notifyAll()
唤醒其他线程,与wait一样,调用该方法的线程,必须拥有该对象的锁;否则会抛异常。
被唤醒的线程不会立即执行,需要等待当前线程放弃持有的锁。
前者随机唤醒一个等待线程(即调用该对象wait方法的线程);后者是唤醒所有线程。

registerNatives()
可以简单理解为让JVM找到本地函数,同时可以按照JAVA的风格进行命名;

private volatile String name
线程名,用户可以自己设置:public final synchronized void setName(String name) {…

构造函数
终极构造函数:public Thread(ThreadGroup group, Runnable target, String name,long stackSize) {…

target - 线程需要执行的任务
name - 线程名
stackSize - 线程栈的大小,JVM会按照这个参数分配近似的内存空间。
group - 一组线程的集合,方便线程的统一管理;比如可以查看一共有多少个active状态的线程(返回的是一个估计值),同时interuppt所有线程等。

private static synchronized int nextThreadNum()
从0开始,每次加1;默认情况下不指定线程名会就会以:”Thread-“ + nextThreadNum() 命名,指定线程名后则不会再调用这个方法;

private static synchronized long nextThreadID()
生成threadId,最终赋值为 private long tid;这个属性。

public static native Thread currentThread();
静态方法,获取当前线程

public static native void yield();
静态方法,通知线程调度器,当前线程可以让出自己正在使用的CPU;不过调度器也许会忽略这一个通知。
一般可以用于启发式尝试管理多线程之间的进度,让CPU超负载的线程可以多分得一些CPU;或者是在多线程测试,复现bug阶段可以尝试使用。实际上用的比较少。

public static native void sleep(long millis)
程序暂停执行,但并不放弃锁

public synchronized void start() {
启动一个线程,使得线程由新建状态转为就绪状态。

private void exit() {
系统会自动调用这个方法,以便做一些资源清理工作。

stop()/stop(Throwable obj) {
已经废弃的方法,不推荐使用;作用是强制停止线程运行,后者在JDK8里面已经不在使用,调用时直接会抛异常。
废弃原因:因为stop方法不会释放锁,所以存在安全隐患。

public void interrupt() {
这里涉及到中断机制,如果当前线程正在因为wait/join/sleep方法阻塞,那么会抛出InterruptedException异常,中断状态将被重置,(即终端状态为false);
如果因为IO阻塞,那么会抛出:ClosedByInterruptException异常,并且设置中断状态的True;
如果因为nio的Selector机制被阻塞,那么会设置中断状态,并且selection operation会立即返回;

然后设置了interrupt状态之后,需要线程自己主动检测,才能处理;并不一定会导致线程终止。

public static boolean interrupted()
静态方法,检测当前线程是否被interrupted

public boolean isInterrupted()/private native boolean isInterrupted(boolean ClearInterrupted)
判断某个线程是否interrupted,并且可以设置是否重置interrupted的状态。

destroy()
JDK1.8已经废弃;该方法会直接销毁线程,而不做任何资源/锁的释放工作

public final native boolean isAlive()
判断线程是否存活,一个线程启动之后,在死亡之前的状态,都是存活状态;

suspend()/resume()
暂停线程/恢复暂停的线程;由于线程在暂停的时候,并不会释放锁,因此存在较大的死锁隐患,所以已经废弃;

public final void setPriority(int newPriority) { / getPriority()
设置线程权重,1 ~ 10;会受所在group最高权重的限制。

setName/getName
设置或获取线程的名字

public final ThreadGroup getThreadGroup()
获取线程所在的组

public static int activeCount()
获取当前线程组中存活线程的数量

public static int enumerate(Thread tarray[])
将当前线程所在的线程组中存活的线程拷贝到tarray中。可以通过activeCount来粗略确定tarray的大小,如果数组太小,那么多余的线程将被忽略,不会抛异常。返回值是完成拷贝线程的数量。

public native int countStackFrames();
已废弃,统计当前线程的所用栈帧。因为必须挂起当前线程(调用suspend方法),所以已经废弃。

public final synchronized void join(long millis)/join()/
本质上借助wait方法,使用循环实现对当前线程阻塞,直到调用的线程对象死亡。

public static void dumpStack()
打印当前线程堆栈

public final void setDaemon(boolean on)/isDaemon()
设定当前线程为守护线程,需要在start之前设定;后者判断当前线程是否为守护线程;
对于虚拟机来说,如果运行的所有线程都是守护线程,那么虚拟机将会自动退出。

public final void checkAccess()
检测当前运行的线程是否有权力修改this线程对象。如果当前线程不能访问this线程对象,则会抛出异常。

toString()
默认会输出线程名,权重;如果group不等于null,也会输出group的名字。

public static native boolean holdsLock(Object obj);
判断当前线程是否持有锁对象

public StackTraceElement[] getStackTrace()
获取Thread对象的线程栈。

public static Map<Thread, StackTraceElement[]> getAllStackTraces()
获取所有线程的线程栈

public State getState()
获取线程状态:NEW/RUNNABLE(就绪状态,但未必在运行)/BLOCKED(等待锁)/WAITING(调用wait()、join()、park()之后的状态)/TIMED_WAITING(调用wait(long)、join(long)、parkUntil、parkNanos之后)/TERMINATED 已经完成执行

public static void setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler eh) {/getDefaultUncaughtExceptionHandler
静态方法,设置线程异常时默认的处理方式;

public void setUncaughtExceptionHandler(UncaughtExceptionHandler eh)/getUncaughtExceptionHandler()
设置线程异常的处理方式;

8. Java 锁相关的概念

公平锁与非公平锁
公平锁是按照申请锁的顺序来赋予线程锁,即先申请锁的线程将先得到锁;
非公平锁指的是多线程获取锁的顺序与申请顺序并不一致;
非公平锁更有利于实现高吞吐量,高并发;但是可能会造成某个线程一直得不到锁,任务无法处理。
Synchronized 是非公平锁;ReentrantLock可以通过构造参数指定是否为公平锁

可重入锁
在一个线程获取锁之后,执行过程中,可以再次获取该锁;
Synchronized是可重入锁,可重入锁可以避免死锁。

独享锁&共享锁;读锁&写锁
独享锁一次只能被一个线程持有,一个线程获取到锁之后,其他线程将不能够再获取锁;
共享锁可以被多个线程共同持有。
读写锁是供享与独享锁的一种具体实现。
Synchronized和ReentrantLock 是独享锁
ReadWriteLock其读锁是共享锁、写锁是独享锁;其中读与写、写与写是互斥的。
读写锁是通过AQS(AbstractQueuedSynchronizer)实现的;关于AQS框架需要单独介绍。

悲观锁&乐观锁
事务在不加锁的情况下处理各自影响的那部分数据,在提交数据时先检查有没有其他事务更新数据,如果有则回滚本次事务,没有则提交。实现:一般是通过在数据库中增加一个时间戳或者版本字段,在读取数据时获取更新字段,处理完之后更新数据时加判断条件,保证时间戳相等(update table_name set column = new_data,version = old_version+1 where version = old_version)。
应用场景:事务之间冲突较小,发生回滚情况不多的场景。
悲观锁-先加锁,再访问处理。加锁会增加额外的开销,还可能会产生死锁。整体的并发和吞吐量会降低。

分段锁
以CurrentHashMap为例,将元素分到不同的桶,在操作时先确定元素在哪个桶,然后仅仅对这个桶进行加锁;
这样设计更有利于高并发

偏向锁&轻量级锁&重量级锁
这三种锁都是针对synchronized块来说的,是JVM针对同步块做出的专门优化。
为什么要优化?JDK6之前,synchronized是重量级锁,加锁和解锁都需要依赖于操作系统底层的Mutex Lock来实现,会涉及到从用户态转换成内核态,这种转换成本比较高。
对象的锁的状态会存在对象头中,(对象头中除了锁状态之外,还有HashCode,GC分代年龄等);
锁状态通过锁的标志位来判断,01 标识可偏向状态,但具体是不是偏向锁需要通过其他位来判断,有一个位置专门记录是否位偏向锁,0标识无锁,1表示偏向锁。00表示轻量级锁,10表示重量级锁。

先解释轻量级锁,加锁过程如下:首先在线程栈帧中创建一个存储锁记录的空间,然后将对象头信息拷贝到这个空间中;然后使用CAS的方式,尝试将对象的对象头中的一个指针指向到锁记录空间,同时锁记录空间的一个owner字段会指向对象头。如果指向成功,则轻量级锁获取成功,锁标志位变为00。

解锁过程如下,通过CAS操作,尝试用锁记录空间中的对象替换当前对象的对象头。如果替换成功,则整个同步过程完成。如果替换失败,说明有其他线程尝试获取该锁,要在释放锁的同时,唤醒被挂起的线程,这时候也就升级为重量级锁。

从轻量级锁的加解锁过程来看,如果不存在多线程同时争抢一把锁,则不需要在用户态和核心态之间切换。

偏向锁:轻量级锁的加解锁操作需要CAS操作,如果一直是同一个线程来获取锁,那么便有更好的方式来实现,即偏向锁。偏向锁顾名思义就是偏向于某个线程(实际上是偏向于第一个获取锁的线程);
加锁过程:首先判断标志位是否为可偏向状态,如果是可偏向状态,则判断线程ID是否是自身线程的ID,如果是则执行同步代码,如果不是则通过CAS操作竞争锁(也有可能是第一个线程第一次获取锁,没有竞争,但依然需要通过CAS方式加锁,后面该线程再获取锁将不在需要CAS操作,这是对轻量级锁优化),竞争成功则将线程ID设置为自身ID,否则表示有竞争,那么在全局安全点时(全局安全点指没有字节码正在执行的时间点),偏向锁升级为轻量级锁。
解锁过程:偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,否则线程不会主动去释放偏向锁。释放过程即上面提到的升级为轻量级锁的过程。

自旋锁
自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

但是线程自旋是需要消耗cup的,说白了就是让cup在做无用功,如果一直获取不到锁,那线程也不能一直占用cup自旋做无用功,所以需要设定一个自旋等待的最大时间。

如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。

关于自旋时间的选择
jdk1.5这个限度是写死的,在1.6引入了适应性自旋锁,它是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时JVM还针对当前CPU的负荷情况做了较多的优化:

如果平均负载小于CPU数则一直自旋
如果有超过(CPU数/2)个线程正在自旋,则后来线程直接阻塞
如果CPU处于节电模式则停止自旋
如果正在自旋的线程发现了进入临界区的线程变化则延迟自旋时间(自旋计数)或进入阻塞
自旋时会适当放弃线程优先级之间的差异

自旋锁的应用,在重量级锁竞争的时候,会使用到,一定程度上避免线程进入锁等待队列(因为一旦进入锁等待队列,就是一个阻塞状态,而阻塞状态的唤醒需要内核态与用户态进行切换,比较耗资源),因为这样所以synchronized是非公平锁。重量级锁简图:
重量级锁竞争
另外在CAS中也用到了自旋。

最后放一张比较详细的图(网上拷贝,并不保证图中没有错误)
重量级锁竞争

面试

  1. 产生死锁的条件
    • 资源互斥性,即某些资源在某些条件下只能被一个线程使用
    • 占用与等待,已经占用一个资源,并且还有资源未满足,等待其他线程释放资源
    • 不可抢占,已经获得的资源,在未使用完之前,不能被其他线程抢占
    • 循环等待,存在一个线程链,每个线程都占有其他线程可能需要的资源,并且等待的资源也可能被其他线程占用。