Java Web基础篇之Java多线程

1、多线程与进程

1.1、是什么?

线程有时被称为轻量进程(Lightweight Process,LWP),是程序执行流的最小单元,进程与线程区别如下:一个进程至少有一个线程.。

1.2、扩展:

进程与线程区别,线程的划分尺度小于进程,使得多线程程序的并发性高。
另外,进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率。
线程在执行过程中与进程还是有区别的。每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口。但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
从逻辑角度来看,多线程的意义在于一个应用程序中,有多个执行部分可以同时执行。但操作系统并没有将多个线程看做多个独立的应用,来实现进程的调度和管理以及资源分配。这是进程与线程的重要区别。
线程状态转换:
线程状态转移图

1.3、为什么?

单线程慢,多核CPU,操作系统支持多线程,提高CPU利用率,读文件,解析文件,数据入库,服务间发送请求,任务并发时为了提高效率,都需要使用多线程

1.4、怎么做?

继承Thread类、实现Runnable接口或使用Future和Callable三种方式运行线程,比如,网络I/O操作时,需要每次启动新的线程。网络IO,阻塞IO的局限,使用线程池,避免线程数过多 耗尽JVM资源(虚拟机栈)或用户线程数,ThreadPoolFactory提供线程池构建工厂类,而concurrent包提供 闭锁 栅栏 等一系列可以保证线程安全性的类

1.5、好不好?

Java完美支持大多数场景的多线程并发,构建高性能应用的基石。


2、volatile与synchronized

2.1、volatile两层含义:

1、内存可见,写操作先行于读操作
2、禁止指令重排序

2.2、volatile与synchronized区别

 volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
 volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
 volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
 volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
 volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化

参考
volatile和synchronized的区别

相关:
从volatile和synchronized的底层实现原理看Java虚拟机对锁优化所做的努力

2.3、synchronized原理

2.3.1、synchronized实现原理

使用monitorenter & monitorexit指令,monitorenter后不允许其他线程进入,进入一个监控器中等待
sychronized实现

监视器的实现:
当有多个线程同时请求某个对象监视器时,对象监视器会设置几种状态来区分请求的线程:
Contention List:所有请求锁的线程被首先放置在该竞争队列中,
Entry List:Contention List 中有机会获得锁的线程被放置到Entry List
Wait Set:调用wait()方法被阻塞的线程被放置到Wait Set中
OnDeck:任何一个时候只能有一个线程竞争锁 该线程称作OnDeck
Owner:获得锁的线程成为Owner
!Owner:释放锁的线程
转换关系如下图:
监视器实现

参考:
彻底了解synchronized(推荐)
synchronized实现之对象监视器monitor的实现

2.3.2、synchronized锁代码块和锁函数区别

1、synchronized作用静态方法或使用(xx.class)锁定代码块:类锁,在同个类内,所属线程独占类锁,其他线程阻塞。

2、synchronized作用于非静态方法或使用(this)作用于代码块:类的实例锁,在同个实例对象内,所属线程独占对象锁,其他线程阻塞;不同的实例对象内无影响。

参考
synchronized对于加锁代码块、方法以及全局(static)锁的详细对比


3、ThreadLocal

3.1、ThreadLocal基本应用

ThreadLocal可以实现每个线程绑定自己的值,即每个线程有各自独立的副本而互相不受影响。一共有四个方法:get, set, remove, initialValue。可以重写initialValue()方法来为ThreadLocal赋初值。SimpleDateFormat不是线程安全的,可以通过如下的方式让每个线程单独拥有这个对象:

private static final ThreadLocal<DateFormat> DATE_FORMAT = new ThreadLocal<DateFormat>(){
  @Override protected DateFormat initialValue(){
      return new SimpleDateFormat("yyyy-mm-dd");
  }  
};
private static final ThreadLocal<StringBuilder> stringBuilder = new ThreadLocal<StringBuilder>(){
    @Override protected StringBuilder initialValue(){
        return new StringBuilder(256);
    }
};

3.2、使用注意事项

ThreadLocal不是用来解决对象共享访问问题的,而主要提供了线程保持对象的方法和避免参数传递的方便的对象访问方式。ThreadLocal使用场合主要解决多线程中数据因并发产生不一致的问题。ThreadLocal为每个线程中的并发访问的数据提供一个副本,通过访问副本来运行业务,这样的结果是耗费了内存,但大大减少了线程同步所带来的线程消耗,也减少了线程并发控制的复杂度(空间换时间保证线程安全)。

通过ThreadLocal.set()将新创建的对象的引用保存到各线程的自己的一个map(Thread类中的ThreadLocal.ThreadLocalMap的变量)中,每个线程都有这样一个map,执行ThreadLocal.get()时,各线程从自己的map中取出放进去的对象,因此取出来的是各自自己线程中的对象,ThreadLocal实例是作为map的key来使用的。ThreadLocalMap初始容量为16的数组table,如果个数超过threshold(capacity*2/3)之后就会按照原来容量的2倍进行扩容。每当有新的ThreadLocal添加到ThreadLocalMap的时候会通过nextHashCode()方法计算hash值:

nextHashCode.getAndAdd(HASH_INCREMENT);//HASH_INCREMENT = 0x61c88647;

然后根据key.threadLocalHashCode & (table.length - 1);计算出在table中的位置i,如果发生冲突,那就根据((i + 1 < len) ? i + 1 : 0)计算出新的位置i。只是找下一个可用空间并在其中插入元素(线性探测法)。其中0x61c88647为((根号5 -1 )/ 2)* 2的32次方,与斐波那契额和黄金分割有关,为了让哈希码能均匀地分布在2的n次方的数组内。

ThreadLocalMap中每个Entry的key(ThreadLocal实例)是弱引用,value是强引用(这点类似于弱引用WeakHashMap)。当把threadLocal实例置为null以后,没有任何强引用指向threadLocal实例,所以threadLocal将会被gc回收,但是value却不能被回收,因为其还存在于ThreadLocalMap的对象的Entry之中。只有当前Thread结束之后,所有与当前线程有关的资源才会被GC回收。所以如果在线程池中使用ThreadLocal,由于线程会复用,而又没有显示的调用remove的话的确是会有可能发生内存泄露的问题。线程复用还会产生脏数据。由于线程池会重用Thread对象,那么与Thread绑定时的类的静态属性ThreadLocal变量也会被重用。如果在实现的线程run()方法中不显式地调用remove()清理与线程相关的ThreadLocal信息,那么倘若下一个线程不调用set设置的初始值,就可能get到重用的线程信息,包括ThreadLocal所关联的线程对象的value值。

其实在ThreadLocalMap的get或者set方法中会探测其中的key是否被回收(调用expungeStaleEntry方法),然后将其value设置为null,这个功能几乎和WeakHashMap中的expungeStaleEntries()方法一样。因此value在key被gc后可能还会存活一段时间,但最终也会被回收,但是若不再调用get或者set方法时,那么这个value就在线程存活期间无法被释放。

3.3、ThreadLocal使用建议

ThreadLocal类变量因为本身定位为要被多个线程来访问,它通常被定义为static变量。
能够通过值传递的参数,不要通过ThreadLocal存储,以免造成ThreadLocal的滥用。
在线程池的情况下,在ThreadLocal业务周期处理完成时,最好显示的调用remove()方法,清空“线程局部变量”中的值。
在正常情况下使用ThreadLocal不会造成OOM, 弱引用的知识ThreadLocal,保存值依然是强引用,如果ThreadLocal依然被其他对象应用,线程局部变量将无法回收。

3.4、相关阅读:

多线程小抄集(新编二)
多图深入分析ThreadLocal原理


4、线程池

线程池的作用:利用线程池管理并复用线程、控制最大并发数等;实现任务线程队列缓存策略和饱和策略;实现某些与时间相关的功能,如定时执行、周期执行等;隔离线程环境。可以通过ThreadPoolExecutor来创建一个线程池:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) 

1、corePoolSize 表示常驻核心线程数。如果等于0,则任务执行完之后,没有任何请求进入时销毁线程池的线程;如果大于0,即使本地任务执行完毕,核心线程也不会被销毁(除非allowCoreThreadTimeOut设置为true)。向线程池提交一个任务时,若线程池已创建的线程数小于corePoolSize,即便此时存在空闲线程,也会通过创建一个新线程来执行该任务,直到已创建的线程数大于或等于corePoolSize时,才会根据是否存在空闲线程,来决定是否需要创建新的线程。除了利用提交新任务来创建和启动线程(按需构造),也可以通过 prestartCoreThread() 或 prestartAllCoreThreads() 方法来提前启动线程池中的基本线程。

2、maximumPoolSize 表示线程池能够容纳同时执行的最大线程数(>0)。线程池中允许的最大线程数。**线程池的阻塞队列满了之后,如果还有任务提交,如果当前的线程数小于maximumPoolSize,则会新建线程来执行任务。**注意,如果使用的是无界队列,该参数也就没有什么效果了。如果maximumPoolSize与coolPoolSize相等,即是固定大小线程池。

3、keepAliveTime 表示线程池中的线程空闲时间,当空闲时间到达keepAliveTime值时,线程会被销毁,直到只剩下corePoolSize个线程为止。在默认情况下,当线程池的线程数大于corePoolSize时,这个参数才会起作用。但是当ThreadPoolExecutor的allowCoreThreadTimeOut变量设置为true时,核心线程超时后也会被回收,可以通过ThreadPoolExecutor.allowCoreThreadTimeOut(boolean value)设置。

4、unit 表示时间单位。keepAliveTime的时间单位通常是TimeUnit.SECONDS。

5、workQueue 表示缓存队列。对于无界队列,可以忽略该参数。
如果运行的线程数少于 corePoolSize,则Executor 始终首选添加新的线程,而不进行排队。
如果运行的线程数等于或多于 corePoolSize,则Executor 始终首选将请求加入队列,而不添加新的线程。
如果无法将请求加入队列,则创建新的线程,除非创建此线程超出 maximumPoolSize,在这种情况下,任务将被拒绝。
ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
LinkedBlockingQueue:基于链表结构的有界/无界阻塞队列,FIFO。
SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作,反之亦然。
PriorityBlockingQueue:具有优先级的无界阻塞队列。

6、threadFactory 用于创建新线程。由同一个threadFactory创建的线程,属于同一个ThreadGroup,创建的线程优先级都为Thread.NORM_PRIORITY,以及是非守护进程状态。threadFactory创建的线程也是采用new Thread()方式,threadFactory创建的线程名都具有统一的风格:pool-m-thread-n(m为线程池的编号,n为线程池内的线程编号)

7、handler 表示执行饱和策略的对象。当超过workQueue的任务缓存区上限的时候,就可以通过该策略处理请求。可以实现自己的拒绝策略,例如记录日志等等,实现RejectedExecutionHandler接口即可。
拒绝策略有4种:
a. AbortPolicy:直接抛出异常RejectedExecutionException,默认策略
b. CallerRunsPolicy:调用者所在线程来运行该任务,此策略提供简单的反馈控制机制,能够减缓新任务的提交速度。
c. DiscardPolicy:直接丢弃任务
d. DiscardOldestPolicy:如果执行程序尚未关闭,则位于工作队列头部的任务将被删除,然后重新尝试执行任务(如果再次失败,则重复此过程)。

可以使用两个方法向线程池提交任务,分别为execute()和submit()方法。execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。submit()方法用于提交需要返回值的任务,线程池会返回一个Future类型的对象,通过这个对象可以判断任务是否执行成功。如Future future = executor.submit(task);
利用线程池提供的参数进行监控,参数如下:
getTaskCount():线程池需要执行的任务数量。
getCompletedTaskCount():线程池在运行过程中已完成的任务数量,小于或等于taskCount。
getLargestPoolSize():线程池曾经创建过的最大线程数量,通过这个数据可以知道线程池是否满过。如等于线程池的最大大小,则表示线程池曾经满了。
getPoolSize():线程池的线程数量。如果线程池不销毁的话,池里的线程不会自动销毁,所以这个大小只增不减。
getActiveCount():获取活动的线程数。

参考
多线程小抄集(新编二)
多线程小抄集(新编三)


扩展:一个线程连着调用两次start会出现什么状况

第二次调用必然会抛出IllegalThreadStateException,这是一种运行时异常,多次调用start被认为是编程错误。

参考
一个线程两次调用start()方法会出现什么情况


5、AQS及锁的实现

AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。
看个AQS(AbstractQueuedSynchronizer)原理图:
 AQS(AbstractQueuedSynchronizer)原理图
AQS,它维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。这里volatile是核心关键词,具体volatile的语义,在此不述。state的访问方式有三种:
• getState()
• setState()
• compareAndSetState()
AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:
• isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
• tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
• tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
• tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
• tryReleaseShared(int):共享方式。尝试释放资源,成功则返回true,失败则返回false。
• 以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS(Compare and Swap)减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。
一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。

acquire流程:

  1. 调用自定义同步器的tryAcquire()尝试直接去获取资源,如果成功则直接返回;
  2. 没成功,则addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
  3. acquireQueued()使线程在等待队列中休息,有机会时(轮到自己,会被unpark())会去尝试获取资源。获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
  4. 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
    acquire流程
    release流程:
    release()是独占模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果彻底释放了(即state=0),它会唤醒等待队列里的其他线程来获取资源。(共享的话,会唤醒其他满足条件的线程)。

注意:其中用到的LockSupport是用来创建锁和其他同步类的基本线程阻塞原语。
LockSupport中的park() 和 unpark() 的作用分别是阻塞线程和解除阻塞线程,而且park()和unpark()不会遇到“Thread.suspend 和 Thread.resume所可能引发的死锁”问题。
因为park() 和 unpark()有许可的存在;调用 park() 的线程和另一个试图将其 unpark() 的线程之间的竞争将保持活性。

参考:
AQS浅析
Java并发-AQS及各种Lock锁的原理
Java并发之AQS详解
Java中的队列同步器AQS和ReentrantLock锁原理简要分析
JUC源码解析(强烈推荐)


6、JUC介绍

6.1、JUC类图

JUC类图

6.2、CAS无锁

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值

参考
非阻塞同步算法与CAS(Compare and Swap)无锁算法

6.3、ABA问题

ABA: CAS算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。
比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且two进行了一些操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后one操作成功。尽管线程one的CAS操作成功,但是不代表这个过程就是没有问题的。如果链表的头在变化了两次后恢复了原值,但是不代表链表就没有变化。因此前面提到的原子操作AtomicStampedReference/AtomicMarkableReference就很有用了。这允许一对变化的元素进行原子操作。

参考
CAS及ABA问题解决


7、相关阅读

Java多线程编程核心技术(推荐专题)
JUC源码解析(强烈推荐)

猜你喜欢

转载自blog.csdn.net/zangdaiyang1991/article/details/89949975