目录
- *并行与并发
- *Java线程的7种状态
- *Java线程创建的4种方式(优化Callable)
- *start()和run()的区别
- *实现Runnable接口比继承Thread类的优势(可改进)
- *为什么使用线程池
- *执行execute()方法和submit()方法的区别?(可改进)
- *Runnable和Callable的区别(可改进)
- 如何创建线程池
- *JUC包下的Atomic类(需优化)
- *CAS和AQS(为什么要解决ABA问题、AQS优化)
- *synchronized
- *ReentrantLock(公平锁和非公平锁可更新)
- *volatile
- *volatile与synchronized
- *Thread.sleep()和object.wait()的区别
- *Thread.sleep()和Thread.yield()的区别
- *t.join()和t.join(long n)
- *守护线程
- *Java中的锁
- *ThreadLocal
- 基于数组的阻塞队列ArrayBlockingQueue原理(可优化)
- *线程池参数设置
*并行与并发
- 并行:在同一时刻,有多个线程在多个处理器上同时执行;
- 并发:在同一时刻,只能有一个线程执行,但多个线程被快速轮转,使得在宏观上具有多线程同时执行的效果;
*Java线程的7种状态
- 新建状态(New):通过实现Runnable接口或继承Thread类,new一个线程实例后,线程就进入了新建状态;
- 就绪状态(Ready):线程对象创建成功后,调用该线程的start()函数,线程进入就绪状态,该状态的线程等待获取CPU时间片;运行状态下时间片用完或调用了Thread.yield()函数,线程回到就绪状态;
- 运行状态(Running):线程获取到了CPU时间片,正在运行自己的代码;
- 等待状态(Waiting):1 运行状态的线程执行object.wait()、t.join()、LockSupport.park()等,将进入等待状态,其中object.wait()和t.join()会令JVM把该线程放入锁同步队列,LockSupport.park()不会释放当前线程占有的锁资源;2 等待状态的线程不会被分配CPU时间片,等待被主动唤醒,否则一直处于等待状态;2 唤醒:通过notify()、notifyAll()、调用join的线程t执行完毕,会唤醒锁等待队列中的线程,出队的线程回到就绪状态;另一个线程调用执行LockSupport.unpark(t)唤醒指定线程,该线程回到就绪状态;
- 超时等待状态(Timed Waiting):1 与等待状态的区别是:超时等待状态的线程到达指定时间后会自动唤醒;2 以下函数会进入超时等待状态:object.wait(long)、t.join(long)、sleep(long)、LockSupport.parkUtil(long),其中object.wait(long)、t.join(long)会令JVM把线程放入锁等待队列;3 唤醒:超时时间到了,或通过notify()、notifyAll()、调用join的线程t执行完毕,会唤醒锁等待队列中的线程,出队的线程回到就绪状态;非锁等待队列中的线程等超时了就回到就绪状态;
- 阻塞状态(Blocked):运行状态的线程获取同步锁失败或发出IO请求,进入阻塞状态,如果是获取同步锁失败则JVM将该线程放入锁同步队列;
- 终止状态(Terminated):线程执行结束或执行过程中因异常意外终止就进入终止状态,线程一旦终止就不能复生,这是不可逆的过程;
(yield让步、join加入、park停)
一张图总结Java线程状态
*Java线程创建的4种方式(优化Callable)
- 继承Thread类:1.1 定义一个Thread类的子类,并重写run()方法,该run()方法的方法体即线程执行体;1.2 创建Thread子类的实例,即创建线程对象;1.3 调用Thread对象的start()方法来启动该线程;
- 实现Runnable接口: 2.1 定义一个Runnable接口的实现类,重写接口的run()方法,该run()方法的方法体即线程执行体;2.2 创建Runnable实现类的对象;2.3 使用Runnable实现类的对象作为Thread对象的target,该Thread对象即线程对象;2.4 调用Thread对象的start()方法来启动线程;
- 通过Callable和FutureTask创建线程:3.1创建Callable接口的实现类,并重写call()方法,该call()方法即线程执行体,并且有返回值;3.2创建Callable实现类的实例,使用FutureTask对象来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值;3.3使用FutureTask对象作为Thread对象的target创建并启动新线程;3.4调用FutureTask对象的get()方法来获得线程执行结束后的返回值;
- 基于线程池:4.1 如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建和销毁线程会大大降低系统效率;4.2 我们可以通过线程池来实现线程的复用,线程池:一个容纳多个线程的容器,其中的线程可以反复使用,无需反复创建销毁线程而消耗过多资源;4.4 ExecutorService pool = Executors.newFixedThreadPool(10);pool.execute(new Runnable()…);
*start()和run()的区别
- 调用Thread类的start()方法来启动线程,真正实现了多线程,这时此线程处于可运行状态,并没有真正运行,一旦得到cpu时间片,就开始运行并执行run()方法,而此时无需等待run()方法执行完毕,即可继续执行主线程下面的代码;
- run()方法只是Thread类的一个普通方法,如果直接调用run()方法,程序中依然只有主线程这一个线程,程序执行路径还是只有一条,要等待run()方法执行完毕后才可继续执行下面的代码;
*实现Runnable接口比继承Thread类的优势(可改进)
- 可以避免Java单继承的局限性;
- 代码可被多个线程共享,代码和线程独立;
- 线程池只能放入实现Runnable或Callable接口的对象,不能放入继承Thread类的对象;
*为什么使用线程池
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗;
- 提高响应速度。当任务到达时,不需要等到线程创建就能立即执行;
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统⼀的分配、调优和监控;
*执行execute()方法和submit()方法的区别?(可改进)
- execute() 用于提交没有返回值的任务;
- submit() 用于提交有返回值的任务。线程池会返回⼀个 Future 类型的对象,可以通过 Future.get() 来获取返回值;
*Runnable和Callable的区别(可改进)
- Callable 的实现方法是 call(),Runnable 的实现方法是 run();
- Callable 任务执行后有返回值。Callable 与 Future 配合使用,运行 Callable 任务和获取一个 Future 对象表示异步计算后的结果;Runnable 任务执行后没有返回值;
如何创建线程池
*方法1:通过Executor框架的工具类Executors来实现
- newFixedThreadPool(固定大小的线程池):如果任务数量大于等于线程池中线程的数量,则新提交的任务将在阻塞队列中排队,直到有可用的线程资源;
- newSingleThreadExecutor(单个线程的线程池):1 确保池中永远有且只有一个可用的线程;2 在该线程停止或发生异常时,会创建一个新线程代替该线程继续执行任务;
- newCachedThreadPool(可缓存的线程池):1 提交新任务时如果有可重用的线程,则重用它们,否则创建一个新线程并将其添加到线程池中;2 线程池的keepAliveTime默认60秒,超过60秒未被利用线程会被终止并从缓存中移除,因此在没有线程任务运行时,newCachedThreadPool不会占用系统资源;3 在有执行时间很短的大量任务需要执行的情况下,newCachedThreadPool能很好地复用线程资源来提高运行效率;
- newScheduledThreadPool(可做任务调度的线程池):可定时调度,可设置在给定延迟时间后执行或定期执行某个线程任务;
- newWorkStealingPool(足够大小的线程池):1 用于创建持有足够数量线程的线程池来达到快速运算的目的;2 JDK根据当前的运行需求向操作系统申请足够多的线程;
例如
ExecutorService executor = Executors.newSingleThreadExecutor();
*方式2:通过ThreadPoolExecutor构造方法创建(饱和策略优化)
《阿里巴巴Java开发手册》中建议线程池不使用 Executors 去创建,而是通过ThreadPoolExecutor 的方式,这样的处理方式一是规避资源耗尽的风险,二是可以更加明确线程池的运行规则。
Executors 返回线程池对象的弊端如下:1. newFixedThreadPool 和 newSingleThreadExecutor:允许请求的队列长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致OOM(OutOfMemoryError);2. newCachedThreadPool 和 newScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE,可能会创建大量线程,从而导致OOM;
以上是ThreadPoolExecutor类提供的4个构造方法,下面分析最长的那个,其他三个采用默认参数:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.workQueue = workQueue;
this.threadFactory = threadFactory;
this.handler = handler;
}
ThreadPoolExecutor 构造函数参数分析:
- corePoolSize:核心线程数;
- maximumPoolSize : 线程池中能拥有最大线程数;
- workQueue : 用于缓存任务的阻塞队列;
- keepAliveTime :当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime 才会被回收销毁;
- unit : keepAliveTime 参数的时间单位;
- threadFactory :executor创建新线程时会用到;
- handler :饱和策略;
corePoolSize、maximumPoolSize、workQueue 三者关系
- 如果没有空闲线程执行该任务且当前运行的线程数少于 corePoolSize ,则添加新线程执行该任务;
- 如果没有空闲线程执行该任务且当前的线程数等于 corePoolSize ,同时阻塞队列未满,则将任务入队列,而不添加新线程;
- 如果没有空闲线程执行该任务且阻塞队列已满同时池中的线程数小于maximumPoolSize ,则创建新线程执行任务;
- 如果没有空闲线程执行该任务且阻塞队列已满同时池中的线程数等于maximumPoolSize ,则根据构造函数中的 handler 指定的饱和策略来拒绝新的任务。
ThreadPoolExecutor 饱和策略
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时, ThreadPoolTaskExecutor 定义了⼀些饱和策略:
- ThreadPoolExecutor.AbortPolicy :抛出RejectedExecutionException,意味着需要对这种情况进行适当的错误处理,默认的处理策略;
- ThreadPoolExecutor.CallerRunsPolicy:用调用者所在的线程来执行任务。即被拒绝的任务在主线程中运行,别的任务只能在被拒绝的任务执行完之后才会继续被提交到线程池执行。适用于对于那些不允许丢失任务的业务场景,例如在金融交易系统中,每一笔交易都不能被丢弃
- DiscardPolicy:直接丢弃任务,不给调用者任何通知,适用于不重要或可重试的任务;
- DiscardOldestPolicy:添加新任务前丢弃阻塞队列中最老的任务,可以确保重要的新任务有机会被执行;
案例
/**
* 定义⼀个简单的Runnable类,需要⼤约5秒钟来执⾏其任务。
*/
public class MyRunnable implements Runnable {
private String command;
public MyRunnable(String s) {
this.command = s;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " Start.Time = " + new Date());
processCommand();
System.out.println(Thread.currentThread().getName() + " End.Time = " + new Date());
}
private void processCommand() {
try {
Thread.sleep(5000);
System.out.println("执行command" + command);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
@Override
public String toString() {
return this.command;
}
}
public class ThreadPoolExecutorDemo {
private static final int CORE_POOL_SIZE = 5;
private static final int MAX_POOL_SIZE = 10;
private static final int QUEUE_CAPACITY = 100;
private static final Long KEEP_ALIVE_TIME = 1L;
public static void main(String[] args) {
//使⽤阿⾥巴巴推荐的创建线程池的⽅式
//通过ThreadPoolExecutor构造函数⾃定义参数创建
ThreadPoolExecutor executor = new ThreadPoolExecutor(
CORE_POOL_SIZE,
MAX_POOL_SIZE,
KEEP_ALIVE_TIME,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(QUEUE_CAPACITY),
new ThreadPoolExecutor.CallerRunsPolicy());
for (int i = 0; i < 10; i++) {
//创建WorkerThread对象(WorkerThread类实现了Runnable 接⼝)
Runnable worker = new MyRunnable("" + i);
//执⾏Runnable
executor.execute(worker);
}
//终⽌线程池
executor.shutdown();//用于关闭线程池。当调用该方法时,线程池将不再接受新的任务,但会继续执行已提交的任务,直到所有任务都完成。
while (!executor.isTerminated()) {
}
System.out.println("Finished all threads");
}
}
可以看到上⾯的代码指定了:
- corePoolSize : 核心线程数为 5;
- maximumPoolSize :最大线程数 10;
- keepAliveTime : 等待时间为 1L;
- unit : 等待时间的单位为 TimeUnit.SECONDS;
- workQueue :任务队列为 ArrayBlockingQueue ,并且容量为 100;
- handler :饱和策略为 CallerRunsPolicy;
pool-1-thread-4 Start.Time = Thu Jul 22 10:03:26 CST 2021
pool-1-thread-3 Start.Time = Thu Jul 22 10:03:26 CST 2021
pool-1-thread-2 Start.Time = Thu Jul 22 10:03:26 CST 2021
pool-1-thread-1 Start.Time = Thu Jul 22 10:03:26 CST 2021
pool-1-thread-5 Start.Time = Thu Jul 22 10:03:26 CST 2021
执行command3
执行command2
执行command1
执行command4
执行command0
pool-1-thread-5 End.Time = Thu Jul 22 10:03:31 CST 2021
pool-1-thread-1 End.Time = Thu Jul 22 10:03:31 CST 2021
pool-1-thread-2 End.Time = Thu Jul 22 10:03:31 CST 2021
pool-1-thread-3 End.Time = Thu Jul 22 10:03:31 CST 2021
pool-1-thread-2 Start.Time = Thu Jul 22 10:03:31 CST 2021
pool-1-thread-4 End.Time = Thu Jul 22 10:03:31 CST 2021
pool-1-thread-3 Start.Time = Thu Jul 22 10:03:31 CST 2021
pool-1-thread-5 Start.Time = Thu Jul 22 10:03:31 CST 2021
pool-1-thread-1 Start.Time = Thu Jul 22 10:03:31 CST 2021
pool-1-thread-4 Start.Time = Thu Jul 22 10:03:31 CST 2021
执行command7
执行command8
执行command6
执行command5
执行command9
pool-1-thread-1 End.Time = Thu Jul 22 10:03:36 CST 2021
pool-1-thread-2 End.Time = Thu Jul 22 10:03:36 CST 2021
pool-1-thread-3 End.Time = Thu Jul 22 10:03:36 CST 2021
pool-1-thread-5 End.Time = Thu Jul 22 10:03:36 CST 2021
pool-1-thread-4 End.Time = Thu Jul 22 10:03:36 CST 2021
Finished all threads
*JUC包下的Atomic类(需优化)
Java的Atomic原子类都放在java.util.concurrent.atomic
包下
常见的几种Atomic类(AtomicStampedReference可改进)
-
基本类型:使用原子的方式操作基本类型
- AtomicInteger:整形原子类
- AtomicLong:长整型原子类
- AtomicBoolean:布尔型原子类
-
数组类型:使用原子的方式操作数组
- AtomicIntegerArray:整形数组原子类
- AtomicLongArray:长整型数组原子类
- AtomicReferenceArray:引用类型数组原子类
-
引用类型:使用原子的方式操作引用变量
- AtomicReference:引用类型原子类;
- AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的ABA问题(由于 CAS 设计机制就是获取某两个时刻变量值(初始预期值和当前值),并进行比较更新,所以说如果在获取初始预期值和当前内存值这段时间间隔内,变量值由 A 变为 B 再变为 A,那么对于 CAS 来说是不可感知的,但实际上变量已经发生了变化(即ABA问题)。解决办法是加版本号,每次更新对版本号 +1,这样当发生 ABA 问题时通过版本号可以得知变量被改动过;
AtomicInteger类的使用
- public final int get():获取当前值
- public final int getAndSet(int newValue):获取当前值,并设置新值
- public final int getAndIncrement():获取当前值,并自增
- public final int getAndDecrement():获取当前值,并自减
- public final int getAndAdd(int delta):获取当前值,并加上预期值
- boolean compareAndSet(int expect, int update):如果当前值等于参数给定的期望值expect,则将值设置为参数给定的更新值update,否则获取最新值重新执行任务+CAS过程;
- public final void lazySet(int newValue):最终设置为newValue,使用lazySet设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
AtomicInteger类的原理
- AtomicInteger 类主要利用 CAS + volatile来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升;
- CAS在数据更新的时候判断一下在此期间是否有其他线程修改了这个数据,如果修改了则不更新,而是取到最新值重新执行任务+CAS过程;
- volatile保证了变量的可见性,使任何时刻任何线程总能拿到该变量的最新值;
*CAS和AQS(为什么要解决ABA问题、AQS优化)
CAS
- CAS:数据更新时判断一下在此期间是否有其他线程修改了这个数据,如果修改了则不更新,而是返回当前最新数据,再重新执行一次任务+CAS这个过程;
- 由于 CAS 设计机制就是获取某两个时刻变量值(初始预期值和当前值),并进行比较更新,所以说如果在获取初始预期值和当前内存值这段时间间隔内,变量值由 A 变为 B 再变为 A,那么对于 CAS 来说是不可感知的,但实际上变量已经发生了变化(即ABA问题)。解决办法是加版本号,每次更新对版本号 +1,这样当发生 ABA 问题时通过版本号可以得知变量被改动过;
AQS
- AQS(Abstract Queued Synchronized,抽象的队列同步器),定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock、ReentrantReadWriteLock、Semaphore、CountDownLatch等;
- AQS维护了一个volatile int state(代表共享资源)和一个CLH队列(FIFO,多线程争用资源被阻塞时会进入此队列);
- AQS核心思想是:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态;如果被请求的共享资源被占用,那么就需要⼀套线程阻塞等待以及被唤醒时锁分配的机制,这个机制是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中;
CLH(Craig,Landin,and Hagersten)队列是⼀个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成⼀个CLH锁队列的⼀个结点(Node)来实现锁的分配。
- AQS使用state来表示同步状态并使用CAS对state进行更新,通过内置的FIFO队列来完成获取资源线程的排队工作;
- AQS定义两种资源共享方式:1 Exclusive(独占,只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:公平锁:按照线程在队列中的排队顺序,先到者先拿到锁;非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的);2 Share(共享,多个线程可同时执行,如Semaphore、CountDownLatch);3 ReentrantReadWriteLock 可以看成是组合式;
private volatile int state;//共享变量,使⽤volatile修饰保证可⻅性
//返回同步状态的当前值
protected final int getState() {
return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
//原⼦地(CAS操作)将同步状态值设置为给定值update(如果当前同步状态的值等于期望值)
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
- 自定义同步器时需要重写下面几个AQS提供的模板方法:
protected boolean tryAcquire(int arg):独占式获取同步状态,试着获取,成功返回true,反之为false
protected boolean tryRelease(int arg):独占式释放同步状态,等待中的其他线程此时将有机会获取到同步状态
protected int tryAcquireShared(int arg):共享式获取同步状态,返回值大于等于0,代表获取成功;反之获取失败
protected boolean tryReleaseShared(int arg):共享式释放同步状态,成功为true,失败为false
- 以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调⽤tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。释放锁之前A线程自己可以重复获取此锁(state会累加),即可重入。但要注意,获取多少次就要释放多少次,这样才能保证state 能回到零态;
*synchronized
用于为方法、代码块提供线程安全,修饰方法、代码块时,同一时刻只能有一个线程执行该方法体或代码块;
synchronized的三种加锁场景
- 作用于实例方法:使用当前对象(this)的锁执行互斥处理,如果对象的一个synchronized方法被某个线程执行,其他线程无法访问该对象的任何synchronized方法(但是可以访问其他非synchronized方法);
- 作用于静态方法:使用类对象的锁执行互斥处理,其他线程无法访问该类任何静态synchronized方法(但是可以访问其他非静态synchronized方法);
- 使用synchronized创建同步代码块:synchronized(对象){},表示使用某对象的锁,只将块中的代码同步,块之外的代码可以被其他线程同时访问;
synchronized的底层原理
- synchronized底层原理属于JVM层面;
- synchronized 同步代码块的情况:synchronized同步代码块使用monitorenter和monitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit 指令指向同步代码块的结束位置。 当执行monitorenter 指令时,线程试图获取锁也就是获取监视器锁monitor(monitor对象存在于每个Java对象的对象头中,synchronized 便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因)。每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增;当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减;当计数器为0的时候,锁将被释放,其他线程便可以获得锁;
- synchronized 同步方法的情况:同步方法的常量池中有一个ACC_SYNCHRONIZED标志。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如果有,则需要先获得监视器锁monitor,然后开始执行方法,方法执行之后再释放监视器锁;当一个线程获得锁后,该对象的计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增;当同一个线程释放锁的时候,计数器再自减;当计数器为0的时候,锁将被释放,其他线程便可以获得锁;
- 无论是monitorenter、monitorexit还是ACC_SYNCHRONIZED都是基于monitor实现的;
JDK1.6之后的synchronized做了哪些优化
- 为了减少获取锁和释放锁带来的消耗,引入了4种锁的状态:无锁(CAS)、偏向锁(可重入)、轻量级锁(自旋锁)和重量级锁,会随着多线程的竞争情况逐渐升级,但不能降级;
- 同时还引进了自旋锁、锁消除、锁粗化等技术来减少锁操作的开销;
synchronized和ReentrantLock
- synchronized和ReentrantLock都是Java中用于实现线程同步的机制;
- synchronized是Java内置的关键词,用于修饰方法或代码块,实现对共享资源的互斥访问。当一个线程进入synchronized修饰的方法或代码块时,它会获得一个锁,其他线程如果也想访问这个锁修饰的资源,则会被阻塞,直到当前线程释放锁;ReentrantLock是Java提供的一个显式锁,它实现了Lock接口,提供了与synchronized相似的线程同步功能。与synchronized不同的是,ReentrantLock需要手动获取和释放锁,通过调用lock()和unlock()方法来实现;
- 锁的获取方式:synchronized是隐式锁,由JVM自动管理,不需要程序员手动获取和释放;而ReentrantLock是显式锁,需要程序员手动获取和释放锁;
- 锁的释放时机:synchronized在程序执行完同步代码块或方法后自动释放锁;而ReentrantLock需要在程序中手动调用unlock()方法来释放锁;
- 两者都是可重入锁:可重入锁也叫递归锁,是指一个线程在外层方法获取了锁,进入内层方法会自动获取锁(比如一个递归函数里有加锁操作,递归过程中这个锁不会阻塞当前线程);
- 锁的公平性:ReentrantLock可以指定是公平锁还是非公平锁,而synchronized只能是非公平锁;
- 锁的等待可中断性:synchronized的锁等待不可中断,一旦线程等待获取锁,必须等待其他线程释放该锁;而ReentrantLock的锁等待可以中断,可以通过interrupt()方法来中断等待线程。
- 等待唤醒机制:synchronized与wait()和notify()/notifyAll()方法相结合可以实现等待唤醒机制,ReentrantLock的等待唤醒机制需要借助于Condition接口,Condition具有很好的灵活性,可以实现多路通知功能。即在⼀个ReentrantLock对象中可以创建多个Condition实例,线程对象可以注册在指定Condition实例中,通过该Condition实例的await和signal/signalAll()方法可以实现注册在该Condition实例上的线程的等待唤醒,从而可以有选择性地进行线程通知,在线程调度上更加灵活 ;而synchronized关键字就相当于整个Lock对象中只有⼀个Condition实例,所有的线程都注册在它⼀个身上,如果执行notifyAll()方法的话就会唤醒所有处于锁等待状态的线程;
*ReentrantLock(公平锁和非公平锁可更新)
- ReentrantLock是一个可重入的独占锁,通过AQS(Abstract Queued Synchronized,抽象的队列同步器)来实现锁的获取与释放;
- ReentrantLock支持公平锁和非公平锁的实现,通过在构造函数ReentrantLock(boolean fair)中传递不同的参数来定义不同类型的锁,默认非公平锁;
*volatile
- 在Java的内存模型中,线程把变量保存到本地内存而不是直接在主存中进行读写,这就可能造成⼀个线程在主存中修改了⼀个变量的值,而另外⼀个线程还继续使用它在本地内存中的变量值的拷贝,造成数据的不⼀致;
- 把变量声明为volatile就指示 JVM,这个变量是不稳定的,被volatile修饰的变量在被修改后需立即同步到主内存,变量在每次使用之前都需从主内存刷新。因此可以使用volatile来保证多线程操作时变量的可见性;
- volatile另外一个作用就是可以防止指令重排,保证了有序性,详情见“单例模式的双重校验锁方式”;
- volatile不能保证被修饰的变量在同一时间只有一个线程访问,因此不能保证原子性;
*volatile与synchronized
1 与并发编程的三个重要特性的关系
- 原子性指一个操作是不可中断的,要么一次全部执行,要不全部不执行。被synchronized修饰的代码在同一时间只能被一个线程访问,在锁未释放之前,无法被其他线程访问到,因此synchronized可以保证原子性;volatile不能保证被修饰的变量在同一时间只有一个线程访问,因此不能保证原子性;
- 可见性指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能立即看到修改的值。synchronized中有一条规则:对一个变量解锁之前,必须先把此变量同步回主存中,因此被synchronized关键字锁住的对象,其值具有可见性;被volatile修饰的变量在被修改后可以立即同步到主内存,变量在每次使用之前都从主内存刷新,因此volatile也可以保证可见性;
- 有序性指程序执行的顺序按照代码的先后顺序执行。synchronized无法禁止指令重排,但是Java程序遵循as-if-serial语义:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变。由于被synchronized修饰的代码同一时间只能被同一线程访问,也就是单线程执行的,因此可以保证有序性;volatile禁止指令重排,这就保证了程序会严格按照代码的先后顺序执行,也就保证了有序性。
2 性能
- 虽然synchronized做了很多优化,如无锁(CAS)、偏向锁(可重入)、轻量级锁(自旋锁)、重量级锁、锁消除、锁粗化等,但它毕竟还是一种锁。无论是使用同步方法还是同步代码块,在同步操作之前都需要进行加锁,同步操作之后都需要进行解锁,这个加锁、解锁的过程是有性能损耗的;synchronized实现的锁本质上是一种阻塞锁,也就是说多个线程要排队访问同一个共享对象;
- volatile是JVM提供的一种轻量级同步机制,它是基于内存屏障实现的,不会有阻塞锁带来的性能损耗问题;
- 综上,volatile的性能表现要比synchronized好;
3 配合
单例模式的双重校验锁方式(考察volatile和synchronized)
public class Singleton {
private volatile static Singleton instance;
private Singleton (){}
public static Singleton getSingleton() {
//先判断对象是否已经实例过,没有实例化过才进⼊加锁代码
if (instance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
instance采用volatile 关键字修饰是很有必要的,instance = new Singleton()这段代码其实是分为三步执行:
- JVM为对象分配内存空间M;
- 在M上为对象初始化;
- 将M的地址赋值给instance;
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致⼀个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用getSingleton() 后发现 instance不为空,因此返回instance,但此时 instance还未被初始化。
使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
4 比较
- volatile 是 JVM 轻量级的同步机制,所以性能比 synchronized 要好;
- volatile 修饰变量,synchronized 修饰代码块或者方法;
- 多线程访问 volatile 不会出现阻塞,synchronized 会出现阻塞;
- volatile 不能保证原子性,synchroinzed 能保证原子性;
*Thread.sleep()和object.wait()的区别
- 原理不同:sleep()是Thread类的静态方法,是线程用来控制自身流程的,它会使此线程暂停执行一段时间,把执行机会让给其他线程;wait()是Object类的方法,用于线程间通信,会使当前持有该对象锁的线程等待,直到其他线程调用notify()或notifyAll();
- 对锁的处理机制不同:调用sleep()不释放锁,调用wait()释放锁;
- 使用区域不同:sleep()可以放在任何地方,wait()必须在同步方法或同步代码块内;
- 是否传参:sleep()必须传参,参数就是休眠时间;wait()可传参也可不传参,传参就是等待参数时间后苏醒,不传参无限等待;
*Thread.sleep()和Thread.yield()的区别
- sleep():让当前线程从Running进入Timed Waiting状态;
- yield():让当前线程从Running进入Runnable状态,表示当前线程愿意暂停执行,让其他线程有机会执行;
*t.join()和t.join(long n)
- t.join():当前线程进入锁等待队列,进入Waiting状态,等待线程t运行结束;
- t.join(long n):当前线程进入锁等待队列,进入Timed Waiting状态,等待线程t运行结束,且最多等待n毫秒;
*守护线程
- 默认情况下,Java进程需要等待所有线程都运行结束,才会结束;
- 有一种特殊的线程称作守护线程,只要其它非守护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束;
- 垃圾回收器线程就是一种守护线程;
*Java中的锁
*乐观锁与悲观锁
- 乐观锁以乐观的思想处理数据,操作数据时不会上锁,在更新的时候判断一下在此期间是否有其他线程修改了这个数据,如果修改了则不更新,而是获取最新数据,再重新执行一次任务再继续这个过程;
- Java中的乐观锁通过版本号机制和CAS(Compare And Swap,比较和交换)算法来实现,在Java中Java.util.concurrent.atomic包下的原子类就是使用乐观锁实现的;
- 悲观锁采用悲观思想处理数据,每次操作数据时都会上锁,其他线程想操作这个数据但拿不到锁只能阻塞;
- Java中的悲观锁大部分基于AQS(Abstract Queued Synchronized,抽象的队列同步器)实现,在Java中synchronized和ReentrantLock等都是典型的悲观锁;
- 乐观锁适用于读多写少(冲突比较小)的场景,因为不用获取锁和释放锁,省去了锁的开销,提升了吞吐量;
- 悲观锁适用于读少写多(冲突比较严重)的场景,如果使用乐观锁会导致线程不断进行重试,反而降低了性能,使用悲观锁比较合适;
*独占锁和共享锁
- 独占锁:1 每次只允许一个线程持有该锁;2 如果一个线程对数据加上了独占锁,那么其他线程不能再对该数据加任何类型的锁;3 获得独占锁的线程既能读数据又能写数据;4 Java中的synchronized和java.util.concurrent(JUC)包中Lock的实现类都是独占锁;
- 共享锁:1 允许多个线程同时持有该锁;2 如果一个线程对数据加上了共享锁,那么其他线程只能对数据再加共享锁,不能加独占锁;3 获得共享锁的线程只能读数据,不能写数据;4 Java中ReentrantReadWriteLock中的读锁就是共享锁;
*互斥锁和读写锁
- 互斥锁是独占锁的一种实现,互斥锁每次只允许一个线程拥有,其他线程只能等待;
- 读写锁是共享锁的一种实现(Java中的ReentrantReadWriteLock),读写锁分读锁和写锁:多个读锁不互斥,读锁与写锁互斥,写锁与写锁互斥;
- 读写锁相比于互斥锁并发程度更高;
*公平锁与非公平锁(可更新)
- 公平锁指不同的线程竞争锁的机制是公平的,即遵循先到先得原则;
- 非公平锁指不同线程竞争锁的机制是不公平的,即遵循随机或就近原则分配锁的机制;
- 公平锁需要维护一个锁线程等待队列,基于该队列进行锁的分配,因此效率比非公平锁低很多;
- Java中的synchronized是非公平锁,ReentrantLock默认也是非公平锁;
/**
* 创建一个可重入锁,true 表示公平锁,false 表示非公平锁。默认非公平锁
*/
Lock lock = new ReentrantLock(false);
*可重入锁(偏向锁)
- 可重入锁也叫递归锁,是指一个线程在外层方法获取了锁,进入内层方法会自动获取锁(比如一个递归函数里有加锁操作,递归过程中这个锁不会阻塞当前线程);
- 对于ReentrantLock,从名字上就可看出是一个可重入锁,synchronized也是可重入锁;
*自旋锁(轻量级锁)
- 自旋锁认为:如果持有锁的线程能在很短的时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态,只需等一等(即自旋),在持有锁的线程释放锁后即可立即获取锁,这样就避免了线程在内核态和用户态之间的切换上导致的锁时间消耗;
- 线程在自旋时会占用CPU,长时间自旋获取不到锁就会造成CPU的浪费,因此自旋锁不适合锁占用时间比较长的并发情况;
- 适合占用锁的时间非常短或锁竞争不激烈的代码块,对性能会有大幅度提升;
- 在JDK1.6又引入了自适应自旋,这个就比较智能了,自旋时间不再固定,由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。如果JVM认为这次自旋也很有可能再次成功那就会自旋较多的时间,如果自旋很少成功,那以后可能就直接省略掉自旋过程,避免浪费CPU资源;
*分段锁
- 分段锁并非实际的锁,用于将数据分段并在每个分段上都单独加锁,把锁进一步细粒度化,以提高并发效率;
- ConcurrentHashMap在内部就是使用分段锁实现的;
*锁升级:无锁(CAS)-偏向锁(可重入锁)-轻量级锁(自旋锁)-重量级锁
- JDK1.6为了提升性能减少获取锁和释放锁带来的消耗,引入了4种锁的状态:无锁、偏向锁、轻量级锁和重量级锁,会随着多线程的竞争情况逐渐升级,但不能降级;
- 无锁:即乐观锁:不锁住资源,多个线程中只有一个能修改资源成功,其它线程会重试;
- 偏向锁:用于在某个线程获取锁后,消除这个线程锁重入的开销,看起来好像这个线程得到了该锁的偏向;偏向锁的实现是通过控制对象
Mark Word
的标志位来实现的,如果当前是可偏向状态,需要进一步判断对象头存储的线程ID是否与当前线程ID一致,如果一致则直接进入; - 轻量级锁:当线程竞争变得比较激烈时,偏向锁会升级为轻量级锁,即线程通过自旋等待其他线程释放锁;
- 重量级锁:如果线程竞争进一步加剧,比如线程的自旋超过一定次数,或者一个线程持有锁、一个线程自旋、又来第三个线程访问时,轻量级锁就会升级为重量级锁,重量级锁会使除了此时拥有锁的线程以外的线程都阻塞,重量级锁其实就是互斥锁;Java中的synchronized关键字内部实现原理就是锁升级的过程:无锁-偏向锁-轻量级锁-重量级锁;
锁优化技术(锁粗化、锁消除)
锁粗化
JVM会分析程序中的同步代码块,如果发现有连续的、频繁的锁操作,可能会将这些操作合并为一个更大的锁,以减少开销。
例:一个循环体中有一个同步代码块,每次循环都会执行加锁解锁操作。
private static final Object LOCK = new Object();
for(int i = 0;i < 100; i++) {
synchronized(LOCK){
// do some magic things
}
}
经过锁粗化后就变成下面这样了:
synchronized(LOCK){
for(int i = 0;i < 100; i++) {
// do some magic things
}
}
*锁消除
锁消除指虚拟机编译器在运行时检测到了共享数据没有竞争的锁,从而将这些锁进行消除。
举例:
public String test(String s1, String s2){
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(s1);
stringBuffer.append(s2);
return stringBuffer.toString();
}
上面代码中,test方法中有三个变量s1、s2、stringBuffer,它们都是局部变量,局部变量在栈上,栈线程私有,因此就算有多个线程访问test方法也是线程安全的。
我们都知道StringBuffer是线程安全的类,append是同步方法,但test方法本来就是线程安全的,为了提升效率,虚拟机帮我们消除了同步锁,这个过程就称为锁消除。
StringBuffer.class
// append 是同步方法
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
一张图总结Java各种锁
*ThreadLocal
- ThreadLocal是一种线程隔离机制,它提供了多线程环境下对于共享变量访问的安全性;
- 在多线程访问共享变量的场景中,一般的解决办法是对共享变量加锁,从而保证在同一时刻只有一个线程能够对共享变量进行更新;但是加锁会带来性能的下降,所以ThreadLocal用了一种空间换时间的设计思想,也就是说在每个线程里面,都有一个容器来存储共享变量的副本,然后每个线程只对自己的变量副本来做更新操作,这样既解决了线程安全问题,又避免了多线程竞争加锁的开销;
- ThreadLocal的实现原理是:Thread类中有一个ThreadLocalMap类型的变量threadLocals,用来存储当前线程的共享变量副本,后续这个线程对于共享变量的操作,都是从这个threadLocals里进行变更,不会影响全局共享变量的值;
- ThreadLocal通过set方法把value值放入调用线程的threadLocals里面并存放起来,其key就是ThreadLocal 对象。Thread里面的threadLocals之所以被设计为map结构,是为了每个线程可以关联多个ThreadLocal变量;
- 将 ThreadLocal 变量尽可能地定义成static,避免频繁创建 ThreadLocal 实例;
8. 应用场景:每条线程需要存取一个同名变量,但每条线程中该变量的值均不相同;
public class ThreadLocaTest {
private static ThreadLocal<String> localVar = new ThreadLocal<>();
static void print(String str) {
//打印当前线程中本地内存中变量的值
System.out.println(str + " :" + localVar.get());
//清除内存中的本地变量
localVar.remove();
}
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
public void run() {
ThreadLocaTest.localVar.set("xdclass_A");
print("A");
//打印本地变量
System.out.println("清除后:" + localVar.get());
}
},"A").start();
Thread.sleep(1000);
new Thread(new Runnable() {
public void run() {
ThreadLocaTest.localVar.set("xdclass_B");
print("B");
System.out.println("清除后 " + localVar.get());
}
},"B").start();
}
}
A :xdclass_A
清除后:null
B :xdclass_B
清除后 null
ThreadLocal内存泄漏问题
- 由于Entry对key是弱引用,如果外部没有强引用指向它,它就会被 GC 回收,导致 Entry 的 key 为空(null),但是由于 Entry对Value是强引用, 导致value 无法被回收,这时「内存泄漏」就发生了,value 成了一个无法被访问,但又无法被回收的对象;
- 如何避免内存泄漏:每次使用完 ThreadLocal 都记得调用 remove()方法清除数据;
- 即使使用不规范,ThreadLocal 内部也做了一些优化,比如:1、调用 set()方法时,ThreadLocal 会采用某种策略进行一定程度的清理,扩容时还会继续检查清理; 2、调用 get()方法时,如果没有直接命中或者向后环形查找时也会进行清理;3、调用 remove()时,除了清理当前 Entry,还会向后继续清理;
代码示例:
ThreadLocal local = new ThreadLocal();
local.set("当前线程名称:"+Thread.currentThread().getName());//将ThreadLocal作为key放入threadLocals.Entry中
Thread t = Thread.currentThread();//断点看此时的threadLocals.Entry数组刚设置的referent是指向Local的,referent就是Entry中的key,但是被WeakReference包装了一下
local = null;//断开强引用,即断开local与referent的关联
System.gc();//执行GC
t = Thread.currentThread();//这时Entry中刚设置的referent是null了,被GC掉了,因为Entry和key的关系是WeakReference,在没有其他强引用的情况下将被回收掉
//如果这里不采用WeakReference,即使local=null,那么也不会回收Entry的key,因为Entry和key是强引用
//当key被回收,key为null,如果这时value外部也没有强引用指向它,那么value就永远也访问不到了,按理也应该被GC回收,但是由于Entry对象还在强引用 value,导致value无法被回收,这时「内存泄漏」就发生了.
//彻底回收最好调用remove,即:local.remove();remove相当于把ThreadLocalMap里的这个元素干掉了,并没有把自己干掉
System.out.println(local);
ThreadLocal的key和value分别是是哪种引用?为什么这么设计?
- key是Entry的弱引用,value是Entry的强引用;
- 如果key是弱引用:当引用ThreadLocal的对象被回收,由于ThreadLocalMap持有key的弱引用,key在下一次垃圾回收时会被回收;value在下一次ThreadLocalMap调用remove/set/get的时候会被清除(清除key为null的记录);
- 如果key是强引用:当引用ThreadLocal的对象被回收,由于ThreadLocalMap持有key的强引用,如果ThreadLocalMap没有手动remove,key不会被回收,导致内存泄漏;如果value是弱引用,value在正常使用的下次垃圾回收就被回收了,显然不合理;
基于数组的阻塞队列ArrayBlockingQueue原理(可优化)
- ArrayBlockingQueue是由数组实现的有界阻塞队列,按照FIFO的原则对元素进行排序;
- 在队列的基础上增加了两个操作:1 支持阻塞插入(在队列满的情况下,会阻塞继续往队列中添加数据的线程,直到队列中有元素被释放)2 支持阻塞移除(在队列为空的情况下,会阻塞从队列中获取元素的线程,直到队列中添加了新的元素);
- 它其实实现了一个生产者/消费者模型,生产者往队列中添加数据、消费者从队列中获取数据,队列满了阻塞生产者,队列空了阻塞消费者;
- 生产者往阻塞队列中添加数据,消费者从阻塞队列中获取数据,分别依靠put()和take()方法完成阻塞和唤醒的操作;
- 要实现这样的一个阻塞队列,需要用到两个关键技术:队列元素的存储、以及线程阻塞和唤醒;
- 而 ArrayBlockingQueue 是基于数组结构的阻塞队列,也就是队列元素是存储在一个数组结构里面,并且由于数组有长度限制,为了达到循环生产和循环消费的目的,ArrayBlockingQueue 用到了循环数组;
- 而线程的阻塞和唤醒,用到了 J.U.C 包里面的ReentrantLock和Condition。 Condition相当于wait/notify在JUC包里面的实现;
*线程池参数设置
- 涉及核心线程数、最大线程数和阻塞队列。如果线程数量阈值设置过大,可能会创建大量线程造成不必要的上下文切换开销;如果线程数量阈值设置过小,可能频繁触发线程池的拒绝策略,影响业务正常运行;阻塞队列长度影响也很大,如果阻塞队列为无界队列,将会导致线程池中非核心线程无法被创建,意味着最大线程数量的设置失效,造成大量任务堆积在阻塞队列中,如果这些任务涉及上下游请求,会造成大量请求超时失败;
- 首先看线程池中要执行的任务类型。1 I/O密集型:线程频繁进行磁盘读写或远程网络通信,磁盘读写的耗时和网络通信的耗时较大,而线程处于阻塞期间不会占用CPU资源,所以线程数量设置超过CPU核心数不会造成问题;2 CPU密集型:就是对CPU的利用率较高的场景,比如循环、递归、逻辑运算等,这种情况下线程数量设置越少,就越能减少CPU的上下文频繁切换。
- 一种计算公式:线程数=CPU核心数 * 期望CPU利用率*(1 + 线程等待时间/线程总时间)。线程等待时间(线程没有使用CPU的时间,比如阻塞在了IO上)。CPU密集型任务中,线程等待时间/线程总时间接近0;在IO密集型任务中,线程等待时间/线程总时间接近1;
- 一种方案:N表示CPU的核心数量,CPU密集型线程数大小设置为N+1,IO密集型线程数大小设置为2N+1。之所以需要+1,是因为这样设置以后,线程在某个时刻发生一个页错误或者因为其他原因暂停时,刚好有一个额外的线程可以确保CPU周期不会中断;
- 设置最大线程数等于线程数以上方案的线程数。如果是核心业务,设置核心线程数接近于以上方案的线程数;如果是非核心业务,设置核心线程数较小于以上方案的线程数,具体数量可根据业务情况具体分析;
- 阻塞队列大小。如果任务是CPU密集型,可设置较大队列来避免频繁的任务调度和上下文切换;如果是IO密集型,可设置较小的队列,因为IO密集型任务会花费大量时间等待IO操作完成;