多线程开启过程
多线程的运行过程:
- 启动java程序,jvm开启一个主线程,并分配对应的主线程栈内存,由jvm调用main方法
- 执行到main方法中的多线程对象的start方法时,start方法入主线程的栈,这个方法中通知jvm去开启一个新线程,在新线程中执行多线程对象的run方法
- jvm开启新线程并分配独立栈空间,多线程对象的run方法入栈执行,于此同时,主线程中的start方法通知完jvm开启线程后就结束并出主线程的栈,主线程继续执行main方法中的代码
- 在主线程中首先入栈的是main方法,所以main方法只要执行完成主线程就结束,同理子线程中先入栈的是多线程对象的run方法,所以多线程对象run方法执行结束后子线程也就结束
多线程特性
原子性问题(加锁)、可见性问题(变量加volatile)、重排序问题(方法加volatile)
java多线程内存模型(可见性问题)
java的多线程直接是相互之间不可见的,线程之间的通信需要通过主内存进行,每一个线程有自己单独的本地内存,本地内存中的值是从主内存中拷贝而来的。比如有一个Thread多线程实现类中有成员变量a默认为true,其中有一个方法可以设置这个变量为false,这个多线程的run方法是根据a的值来执行while循环,当a为false才会结束循环。当我们在main方法中调用这个多线程对象的run方法后,再调用多线程的设置a为false的方法,那么很有可能其实这个子线程中的a变量的值并未改变,因为主线程中调用的子线程对象修改a的方法其实只是修改了主线程的本地内存中a变量的值为false,但由于之后失去了cpu执行权,就导致这个本地内存中的a的值还没写入主内存中,主线程就已经挂了,所以子线程也就不能读取到主线程中修改后的a的值,要解决这种线程间不可见问题的话,使用volatile关键字修饰修改a变量值的方法,这样就能保证这个方法被调用后就能立即去更新主内存中的值。
重排序问题
cpu在执行java代码时,会在不影响代码逻辑的情况下对代码进行重排序,重排序在单线程下没问题,但多线程下会产生问题,使用volatile修饰方法可以禁止重排序
原子性问题
上面的代码中出现了
- 共享资源
- 多线程环境
- 多条针对共享资源的操作语句
如果按理说,运行结束后count值应该为1000,但由于是多线程环境,所以就会出现原子性问题,这里操作共享资源的代码可以被分成两个原子操作,即count+1和count = count,所以当一个demo线程执行完前一句后,还没执行后一句前被其他demo线程抢到cpu执行权,就会导致前一个线程修改后的count值还没有更新到共享资源count上,当这个线程再次抢到cpu时,由于count是共享资源,导致该线程之前所执行count+1后的count值已经被其他线程所执行run方法后的值所覆盖,出现原子性问题,所以最后所有线程执行完后实际运行的结果count<1000
java并发编程中的CountDownLatch
需求:主线程会开启两个子线程,主线程必须要等待两个子线程执行结束后才能结束,这里就要用到CountDownLatch对象
public class CountDownLatchDemo {
public static void main(String[] args) throws Exception {
System.out.println("主线程开始。。。");
//指定当前主线程需要等待2个子线程运行结束才能结束
CountDownLatch countDownLatch = new CountDownLatch(2);
//开启第一个主线程
new Thread(()->{
System.out.println("子线程1:"+Thread.currentThread().getName()+"开始执行");
//业务代码
System.out.println("子线程1:"+Thread.currentThread().getName()+"执行结束");
//子线程1执行完成后将CountDownLatch减一
countDownLatch.countDown();
}).start();
//开启第二个主线程
new Thread(()->{
System.out.println("子线程2:"+Thread.currentThread().getName()+"开始执行");
//业务代码
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("子线程2:"+Thread.currentThread().getName()+"执行结束");
//子线程2执行完成后将CountDownLatch减一
countDownLatch.countDown();
}).start();
//主线程等待countDownLatch减为0才能继续执行
countDownLatch.await();
System.out.println("两个子线程结束,主线程结束。。。");
}
}
并发队列
阻塞队列(ConcurrentLinkedQueue)
当元素个数超出阻塞队列最大限制或元素个数为0时,队列进入阻塞状态,底层使用ReentrantLock进行了加锁
//非阻塞队列
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
queue.offer("1");
queue.offer("2");
queue.offer("3");
//获取队头元素
String peek = queue.peek();
//出队
String poll = queue.poll();
非阻塞队列(BlockingQueue)
又叫高性能堆
向阻塞队列放入元素
//阻塞队列,参数指定阻塞队列的容量
ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<String>(3);
queue.offer("1");
queue.offer("2");
queue.offer("3");
//这里放入第四个元素超出阻塞队列容量限制,会等待5s,若5s内仍处于阻塞态,则返回false
queue.offer("4",5, TimeUnit.SECONDS);
从阻塞队列中取出元素
//阻塞队列,参数指定阻塞队列的容量
ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<String>(3);
//这里因为队列为空,直接poll就会进入阻塞态,若5s内还没有元素被放入,就返回null
queue.poll(5,TimeUnit.SECONDS);
线程池
ThreadPoolExecutor
线程池核心类,java所提供的线程池底层也是通过该对象创建的,构造方法如下:
线程池的配置参数
public ThreadPoolExecutor(int corePoolSize, //线程池的核心池初始线程容量
int maximumPoolSize, //线程池最大线程数
long keepAliveTime, //空闲线程的回收时间
TimeUnit unit, //时间单位
BlockingQueue<Runnable> workQueue//阻塞队列,当线程池线程用完后,用于存放等待线程的工作队列
) {
//调用另一个重载的构造方法,里面多了两个参数
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), //创建线程对象的工厂
defaultHandler); //拒绝策略,当线程池线程用完、阻塞队列也满后,该如何拒绝新的任务,默认拒绝策略是抛出异常
}
根据以上源码,可以创建自定义的线程池:
//创建自定义线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(
3, //核心池线程数
5, //最大线程数
300L, //空闲线程等待时间
TimeUnit.SECONDS, //等待时间单位
new ArrayBlockingQueue<Runnable>(3) //线程池空后存放等待工作的任务的队列
);
//执行myRunnable任务
executor.execute(myRunnable);
线程池工作原理
- 先判断线程池中当前所有线程数与线程池核心池设置的初始容量的大小
- 如果小于则创建新线程执行,执行完成后线程也不会关闭,会去阻塞队列中尝试取出等待的任务进行执行,若阻塞队列为空,则线程进入阻塞状态
- 如果大于则不会创建新线程,而是会将任务放入阻塞队列中
- 如果当前阻塞队列也已经满了,并且当前配置的最大线程数大于核心池线程数大小,就会创建新的线程池执行任务,该线程执行完后也不会立即关闭,而会尝试去阻塞队列获取任务继续执行,当这些线程超过了最大空闲时间都没有获取到任务来执行,就会被关闭
- 如果当前线程池已经达到最大线程数且都在执行任务,且阻塞队列也已经满了,就会执行拒绝策略
线程池拒绝策略
- 默认策略:抛出异常
- DiscardPolicy:丢弃但不抛出异常
- DiscardOldestPolicy:从队列中取出最老任务抛弃,再放入新任务
- 自定义策略:实现拒绝策略接口即可,比如我们可以将被拒绝的任务通过序列化接口保存到本地或数据库中,当线程空闲时再从磁盘中或数据库读取被拒绝的任务进行执行,这样就能保证任务即使被拒绝也最终会被执行
线程池内存队列满了后怎么办?
会在不超过线程池的最大线程数情况下创建新的线程执行,若线程池已经达到最大线程数则会根据设置的拒绝策略进行拒绝
若队列中有很多任务,但是线程池的机器突然宕机怎办?
每个任务在生成时将信息写入数据库中,设计一个字段记录任务的完成状态,在任务被成功执行后修改这个字段,系统重启后就可以从数据库读取未执行的任务继续工作
java提供的线程池
- 定长线程池
//参数为线程池中线程数量,其阻塞队列长度为int的范围
ExecutorService executorService = Executors.newFixedThreadPool(3);
//for循环中生成100个Runable执行任务,每个任务都是打印当前执行该任务的线程名,这里的100个任务都由executorService线程池中的3个线程来执行
for(int i = 0;i < 100;i++)
//execute中是Runable接口实现类,这里使用lambda表达式
executorService.execute(()->System.out.println(Thread.currentThread().getName()));
- 可复用线程池
ExecutorService executorService = Executors.newCachedThreadPool();
源码:
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(
0, //核心池线程为0
Integer.MAX_VALUE, //最大线程数为int范围
60L, //空闲线程最大等待时间60s
TimeUnit.SECONDS,
new SynchronousQueue<Runnable>() //同步阻塞队列
);
}
- 定时执行任务线程池:可以指定在多少时间后才开启线程池执行Runnable任务
- 单线程线程池:线程池中只有一个线程
如何合理配置线程池参数
根据所执行任务的性质可以划分为:CPU密集型任务、IO密集型任务、混合型任务
- cpu密集型:使用较小线程池,线程池线程数量为cpu核心数+1,因为该类任务cpu占用率很高,若开很多线程会导致频繁的线程切换,降低cpu性能
- io密集型:使用较大线程池,线程池线程数量为2*cpu核心数+1,因为io操作不占用cpu,所以可以开更多线程让cpu在等待io完成时去处理其他任务
- 混合型:将不同类型的任务用不同的线程池去处理
tomcat线程池调优
在tomcat的xml配置文件中配置线程池的参数,可以设置的参数和java的线程池参数一致
Callable接口和Future
可以用于创建多线程,与Runnable接口不同,用Callable创建的子线程在执行完成后可以返回一个结果给主线程
源码
@FunctionalInterface
public interface Callable<V> { //可以有返回值,泛型V就是返回值类型
V call() throws Exception;
}
使用举例:
public static void main(String[] args) throws ExecutionException, InterruptedException {
//创建线程池对象
ExecutorService executorService = Executors.newFixedThreadPool(3);
//创建Callable任务,执行5s后才能得到结果
Callable<Integer> callable = ()->{
Thread.sleep(5000);
return 10;
};
//用线程池执行Callable任务,但是任务要执行5s才能得到结果,也就是主线程要等待5s才能拿到子线程的执行结果
Future<Integer> future = executorService.submit(callable);
//主线程继续执行后续代码。。。
//主线程调用future的get方法后,若当前future还没得到子线程返回的执行结果,则主线程会进入阻塞,直到获得结果为止
Integer integer = future.get();
System.out.println(integer);
}
使用callable创建任务时,要用线程池对象的submit方法执行任务
锁
可重入锁
示例代码:
public class MyRunnable implements Runnable{
@Override
public void run() {
get();
}
//非静态同步方法,锁的是当前调用该方法的对象
synchronized void get(){
System.out.println("name:"+Thread.currentThread().getName()+"get");
set(); //这里set方法执行时也需要锁,在底层set获取到的就是get的锁
}
//非静态同步方法,锁的是当前调用该方法的对象
synchronized void set(){
System.out.println("name:"+Thread.currentThread().getName()+"set");
}
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
new Thread(myRunnable).start();
new Thread(myRunnable).start();
}
}
执行结果
可以看到两个线程都能正常在get方法中调用set方法,但实际get和set方法都需要锁,这里实现的原理如下:
这种锁就叫可重入锁,除了sync锁外,ReentrantLock也属于可重入锁,将上述代码以Lock锁实现:
public class MyRunnable2 implements Runnable{
//lock是一个接口,ReentrantLock是一个实现类,也属于可重入锁
Lock lock = new ReentrantLock();
@Override
public void run() {
get();
}
//非静态同步方法,锁的是当前调用该方法的对象
void get(){
//加锁
lock.lock();
System.out.println("name:"+Thread.currentThread().getName()+"get");
set(); //这里set方法执行时也需要锁,在底层set获取到的就是get的锁
//解锁
lock.unlock();
}
//非静态同步方法,锁的是当前调用该方法的对象
void set(){
//加锁
lock.lock();
System.out.println("name:"+Thread.currentThread().getName()+"set");
//解锁
lock.unlock();
}
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
new Thread(myRunnable).start();
new Thread(myRunnable).start();
}
}
sync锁原理
sync是jvm级别锁,可以自动上锁解锁。java中每个对象都有一个相关联的monitor对象,monitor对象中有一个计数器,计数器默认为0,当针对某个对象使用sync后,底层会调用该monitor对象的monitorenter指令上锁,执行完后再执行monitorexit指令解锁。当某个线程成功执行了monitorenter指令后,计数器的值+1,这时其他线程再来获取锁对象时,因为当计数器值已经不为零且不是之前上锁的线程,所以这个线程就会进入阻塞态,直到计数器为0再进行加锁。sync还支持可重入,当sync代码中还有针对同一对象的sync代码时,在进入第二层sync时也会去获取monitor对象,monitor对象会判断当前线程是否是与第一层sync相同的线程,如果是相同的,就将调用monitorenter指令再次上锁,将计数器值再+1,当第二次sync执行完后再执行monitorexit解锁,计数器-1。
读写可重入锁ReentrantReadWriteLock
在进行写入时加写锁,在进行读取时加读锁,多个读锁可以共存,多个读锁和多个写锁、多个写锁之间不能共存
原子类
底层是cas(compare and swap)锁,即无锁机制、自旋锁、乐观锁
cas原理:cas中有3个参数
- V:代表主内存中的值
- E:代表当前线程本地内存的值
- N:代表当前线程想要修改的新值
工作过程: 当前想要设置新值时,会首先将本地内存中的E值与主内存中的V值对比,若一致,就将新值写入主内存;若不一致,说明有其他线程之前操作过这个变量,当前本地线程中的值不是最新的值,即版本不一致,这时就会先去主内存中读取最新的值,之后再进行比较,直到两个值一致,才会去将V值修改为N值
自旋锁和互斥锁的区别
互斥锁:一个线程得到锁资源,其他线程不再能获得锁资源,即互斥
自旋锁:线程会通过循环判断当前本地内存中的E值与主内存中的V值是否相等,相等才能进行操作,不相等则不能操作,实现上锁
公平锁和非公平锁
公平锁:每个线程在获取锁资源时会放入锁的等待队列中,当锁资源空闲后依次按照队列先后顺序获得锁资源。优点是等待锁的线程不会被饿死,缺点就是等待中的线程会被阻塞,而当其获得锁资源被唤醒时要消耗一定的cpu资源
非公平锁:线程在获取锁资源时先直接尝试占有锁资源,如果占用失败再使用公平锁的机制进行等待,有点是不是所有线程都要经历阻塞被唤醒这个过程,cpu利用率提高,缺点是等待线程可能会被饿死
java中的锁默认为非公平锁,如果要构建公平锁,在ReentrantLock的构造方法中传入true就是公平锁
共享锁和独占锁
如读写锁中的读锁是共享锁,synv、lock、写锁都是独占锁
Lock锁和Sync锁的区别
ThreadLocal的应用
- 用户每次访问业务微服务都会携带token,业务微服务远程调用用户微服务,根据token去redis中查询用户详情信息后返回业务微服务,业务微服务会把得到的用户信息绑定到当前线程,后续可以利用该信息做jwt+拦截器或aop+注解进行权限控制
- controller中可以注入request对象,springmvc在前端控制器中会把request对象绑定到当前线程,注入的request代理对象被调用方法时从当前线程中取出
- mybatisplus的分页插件
- sprting实现动态数据源