Java并发

本系列参考自Java面试小抄以及黑马程序员

线程创建

创建线程的方式

  1. Runnable或Callable接口。新建类时实现Runnable接口,然后在Thread类的构造函数中传入MyRunnable的实例对象,最后执行start()方法。
  2. 继承Thread类,重写run()
  3. lambda精简代码:Runnable接口中只有一个抽象化方法且被@FunctionalInterface修饰,这种接口就可以用lambda简化。

lambda是一个匿名函数的简写形式。(参数列表)->{代码};

class MyThread extends Thread {
	@Override
	public void run() {
		System.out.println("My thread is running!");
	}
}
class MyRunnable implements Runnable{
	@Override
	public void run() {
		System.out.println("My Runnable is running!");
	}
}
public class Solution {
	public static void main(String[] args) {
		Thread t = new MyThread();
		t.start();
		Runnable r1 = new MyRunnable();
		Thread t1 = new Thread(r1,tt1);//线程t1创建新的线程tt1
		t1.start();
		Runnable r1 = new Runnable() {
			@Override
			public void run() {
				// TODO Auto-generated method stub
				System.out.println("Running");
			}
		};
		Runnable r2 = () -> {System.out.println("Running");};
		Thread t2 = new Thread(r1);
		t2.start();
		
	}
}

Runnable和Callable区别:

Callable规定重写的方法是call(),Runnable规定的是run()
callable的任务执行后可返回值,Runnable的任务不能返回值
call()可以抛出异常,run()不行
运行Callable任务可以拿到一个Future对象,表示异步计算的结果,提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务执行情况。

start()和run()

调用start()会执行线程的相应准备工作,然后自动执行run()的内容,并不会等待run()的返回,而是直接继续往下运行。也就是说JVM会另起一条线程执行run()方法,起到多线程的效果。
直接运行run(),会将其当作main线程下的普通方法执行,并不会在某个线程中执行它,还是在主线程里执行。

join()

因为start()不等待run()的返回,所以如果run()中有对于数据的操作而没有及时返回的话,start()的取值可能并不正确。t.join():主线程等待t线程运行结束。

线程状态[[进程与线程1#线程的状态转换|相关]]

新建状态:Thread t = new MyThread();
就绪状态:t.start()
运行状态:CPU开始调度就绪状态的线程
阻塞状态:运行状态的线程执行wait()、线程在获取synchronized同步锁失败、线程的sleep()或I/O阻塞。
死亡状态:线程执行完毕或因异常退出了run()。

shutdown()和shutdownNow()的区别
shutdown():关闭线程池,线程池状态为SHUTDOWN,不会再接受新任务,但是队列里的任务得执行完毕。
shutdownNow():关闭线程池,线程池状态为STOP,终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的List。

线程阻塞和死亡

sleep()和wait(),yield()

sleep():Thread类的静态方法。当前进程将睡眠n毫秒,线程进入阻塞状态。时间到了解除阻塞,进入可运行状态,等待CPU的调度。
wait():Object方法,必须与synchronized关键字一起使用,线程进入阻塞状态。当notify或notifyall被调用后,解除阻塞。只有重新占用互斥锁之后才会进入可运行状态。线程不会自动苏醒,需要别的线程调用同一个对象上的notify方法。
yield():暂停当前正在执行的线程对象,让其他有相同优先级的线程执行。只能保证当前线程放弃CPU占用而不能保证其他线程一定能占用CPU。

三种阻塞情况

  1. 等待阻塞:运行状态的线程执行wait()方法后,JVM将线程放入等待序列。
  2. 同步阻塞:运行状态的线程在获取对象的同步锁时,若该同步锁被其他线程占用,则JVM将其放入锁池中。
  3. 其他阻塞:运行状态的线程执行Thread.sleep()Thread.join()方法,或发出I/O请求时,JVM会将该线程设置为阻塞状态。

死亡的三种方式

  1. 正常结束:线程执行完run()call()
  2. 异常结束:线程抛出一个未捕获的Exception或Error
  3. 调用stop():不推荐使用,易导致死锁。

线程安全

对临界资源的竞争。

synchronized

阻塞方式,即用对象锁保证了临界区内代码的原子性。采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其他线程想获得该对象锁时就会被阻塞。必须等执行完synchronized的代码,获得对象锁的线程才会释放锁,并唤醒被阻塞的线程。synchronized会创建一个内存屏障,内存屏障指令保证了所有CPU操作结果都会直接刷到主存中,从而保证操作的内存可见性。

synchronized用法

  • 修饰方法:
    成员方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁
    静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁。
  • 修饰代码块:作用于当前对象实例,指定加锁对象,对给定对象加锁。
//面向过程
public class Solution {
	static int counter = 0;
	static Object obj = new Object();
	public static void main(String[] args) throws InterruptedException {
		Thread t1 = new Thread(() -> {
			for (int i = 0; i < 5000; i++) {
				synchronized (obj) {
					counter++;
				}
			}
		}, "t3");
		Thread t2 = new Thread(() -> {
			for (int i = 0; i < 5000; i++) {
				synchronized (obj) {
					counter--;
				}
			}
		}, "t4");
		t1.start();
		t2.start();
		t1.join();
		t2.join();
		System.out.println("counter:" + counter);
	}
}
//面向对象
class Room {
	private int counter = 0;
	public void increment() {
		synchronized (this) {
			counter++;
		}
	}
	//或将关键词写在方法上
	public synchronized void decrement() {
			counter--;
	}

	public int getCounter() {
		synchronized (this) {
			return counter;
		}
	}
}
//main函数
public static void main(String[] args) throws InterruptedException {
	Room room = new Room();
	Thread t1 = new Thread(() -> {
		for (int i = 0; i < 5000; i++) {
			room.increment();
		}
	}, "t3");
	Thread t2 = new Thread(() -> {
		for (int i = 0; i < 5000; i++) {
			room.decrement();
		}
	}, "t4");
	t1.start();
	t2.start();
	t1.join();
	t2.join();
	System.out.println("counter:" + room.getCounter());
}

synchronized和[[JVM#JMM|volatile]] 的区别

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

synchronized底层实现原理

Monitor(锁/[[进程与线程2#管程|管程]])。synchronized 同步代码块的实现是对应字节码中的 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor的持有权。(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因)
其内部包含一个计数器,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的再执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取而代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
Pasted image 20230606194348.png
Pasted image 20230606194239.png
Pasted image 20230606195238.png

轻量级锁与重量级锁

如果一个对象虽然有多个线程访问,但多线程访问的时间是错开的,就可以使用轻量级锁来优化。语法仍然是synchronized,即先调用轻量级锁,如果失败再调用重量级锁。

  • 创建锁记录(lock record)对象,每个线程的栈帧包含一个锁记录结构,内部存储锁定对象的MarkWord。
  • 让锁记录的object reference指向所对象,并尝试用cas替换object的markword,将markword值存入锁记录。
  • 如果cas替换成功,对象头存储所记录地址和状态00,表示由该线程给对象加锁。
  • 如果cas失败:
    • 如果其他线程已持有该object的轻量级锁,表示有竞争,进入锁膨胀。
    • 如果自己执行了synchronized锁重入,那么再添加一条lock record作为重入的计数。
      Pasted image 20230607094549.png
  • 当退出synchronized代码块,如果有取值为null的锁记录,表示有重入,这是重置锁记录,表示重入计数减一。如果不为null,使用cas将markword的值恢复给对象头,成功则解锁成功,失败则说明轻量级锁进行了锁膨胀或升级成重量级锁。

CAS锁

Compare and Swap,比较并交换,是一条CPU同步原语,是一种硬件对并发的支持,用于管理对共享数据的并发访问。
当且仅当需要读写的内存值V等于旧的预期值A时,CAS通过原子方式用新值B来更新V,否则不会执行任何操作。
CAS的缺陷:

  • ABA问题:只能判断共享变量是否一致,但无法获知共享变量是否被修改过。假设初始条件是A,修改数据时发现是A就会进行修改。但是看到的虽然是A,中间可能发生了A变为B再变为A的情况,数据即使成功修改,也可能有问题。
  • 循环时间长开销:自旋CAS,如果一直循环执行,会给CPU造成风长达的执行开销。
  • 只能保证一个变量的原子操作:如果对多个变量操作,CAS无法保证操作的原子性。

偏向锁

轻量级锁在没有竞争时,每次重入仍然需要执行cas操作,产生锁记录。因此引入偏向锁优化,只有第一次调用synchronized使用cas,减少同一线程获取锁的代价。
如果开启偏向锁(默认开启),markword后三位为101,此时thread、epoch、age都为0。偏向锁默认延迟,不会在程序启动时立即生效,除非加入VM参数-xx:BiasedLockingStartupDelay=0禁用延迟。解锁后该锁偏向于该线程,所以并不会使用cas换回原来的值,而是保持不变。
VM参数-xx:-UseBiasedLocking禁用偏向锁。或者使用代码对象.hashCode();撤销对象的偏向状态。或多个线程访问同一个锁,撤销偏向锁后会将偏向锁升级为轻量级锁。即markword后三位从101->000->001。

批量重偏向

如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程T1的对象仍有机会重新偏向T2,重偏向会重置对象的Thread ID。
当撤销偏向锁阈值超过20次后,jvm会给这些对象加锁时重新偏向至加锁线程。
当撤销偏向锁阈值超过40次后,JVM会认为根本不该偏向,所以整个类的所有对象都会变为不可偏向,新建的对象也是不可偏向的。

锁膨胀

线程1加轻量级锁失败,进入锁膨胀流程:

  • 为object对象申请monitor锁,让object指向重量级锁地址。
  • 自己进入monitor的entrylist blocked中
    Pasted image 20230607095621.png
    当线程0解锁时,使用cas将markword值恢复给对象头,失败。进入重量级解锁流程,按照monitor地址找到monitor对象,设置owner=null,唤醒entrylist中blocked线程
    膨胀方向:偏向锁->轻量级锁->重量级锁。且膨胀方向不可逆。

自旋优化

重量级锁竞争时,如果当前线程自旋成功,即这时持锁线程已经释放了锁,当前线程就可以避免阻塞。(自旋会占用CPU时间,多核CPU才有意义)

体现了synchronized是非公平锁:

  1. 当持有锁线程释放锁时,a)先将锁的持有者owner赋null,b)然后唤醒等待链表中的一个线程。如果有其他线程刚好在尝试获取锁(比如自旋),则可以马上获得锁。(来的早不如来的巧)
  2. 当线程尝试获取锁失败进入阻塞时,放入链表的顺序和最终被唤醒的顺序是不一致的。

锁消除优化

在JIT编译时,丢运行上下文进行扫描,去除不可额能存在竞争的锁,提高运行效率,因为加锁过程很耗时耗力。锁消除功能默认打开,如果需要关闭,需要通过java参数-XX:-EliminateLocks

线程池

使用线程池的好处:

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗
  • 提高响应速度。
  • 提高线程的可管理性。使用线程池可以进行统一的分配、调优和监控。

execute()和submit()的区别

execute()用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否
submit()用于提交需要返回值的任务,线程池会返回一个future类型的对象,通过该对象可以判断任务是否执行成功,同时也可以通过该对象来获取返回值。

线程池核心参数

  • corePoolSize:核心线程大小,正在运行的线程个数
  • maximumPoolSize:线程池最大线程数=核心线程+救急线程
  • keepAliveTime:救急线程的心跳时间,如果救急线程在此时间内没有运行任务,该线程消亡。
  • unit:时间单位——针对救急线程
  • workQueue:阻塞队列。
  • handler:饱和策略。当线程池满了后需要执行的策略。通过RejectedExecutionHandler接口实现。
    • AbortPolicy(默认):让调用者抛出RejectedExecutionException异常
    • CallerRunsPolicy:让调用者运行任务
    • DiscardPolicy:放弃本次任务
    • DiscardOldestPolicy:放弃队列中最早的任务,本任务取而代之
    • ...
    • Dubbo:抛出异常前会记录日志,并dump线程栈的信息
    • Netty:创建一个新线程来执行任务
    • ActiveMQ:带超时等待尝试放入队列
    • PinPoint:使用一个拒绝策略链,逐一尝试策略链中每种拒绝策略。
  • ThreadFactory:线程工厂,可以为线程创建时起个名字

线程池执行任务流程

  1. 线程池执行execute/submit方法向线程池中添加任务,当任务小于核心线程数时,线程池可以创建新的线程
  2. 当任务大于核心线程时,加入阻塞队列中
  3. 如果阻塞队列已满,需要通过比较是否小于线程池最大线程数,当小于则创建救急线程,当大于则执行饱和策略。

一些工厂线程池

  • newFixedThreadPool:创建一个指定工作线程数量的线程池。只有核心线程没有救急线程。阻塞队列是无界的,可以放任意数量的任务。
  • newCachedThreadPool:核心线程数为0,救急线程的空闲生存时间是60s。意味着创建的线程全部都可以回收,且无限创建。队列采用了SynchronousQueue,特点是没有容量,没有线程取则放不进去(一手交钱,一手交货)
  • newSingleThreadPool:希望多任务排队执行,线程固定数为1。
    • 单线程与单线程池的区别:如果任务执行出现异常,单线程则直接退出了,而线程池还会新建一个线程,保证池的正常工作。
    • 单线程池和固定大小为1的线程池的区别:固定大小线程池初始为1,以后还可以修改,而单线程池线程个数始终为1,不能修改。

源码中线程池如何复用线程

源码中ThreadPoolExecutor中有一个内置对象Worker,每个worker都是一个线程,worker线程数量和参数有关,每个worker会从阻塞队列中取数据,通过置换worker中Runnable对象,运行其run方法起到线程置换的效果,这样做的好处是避免多线程频繁线程切换,提高程序运行性能。

AQS

AbstractQueueSynchronizer,是阻塞式锁和相关的同步器工具的框架,定义了锁的实现机制,并开放出扩展的地方,让子类去实现。
用state属性表示资源的状态(独占或共享),子类需要定义如何维护这个状态,控制如何获取和释放锁。
提供了基于FIFO的等待队列+条件队列,等待队列类似于Monitor的EntryList,管理着获取不到锁的线程的排队和释放。条件队列是在一定场景下,对同步队列的补充,实现等待、唤醒机制,类似于Monitor的WaitSet。

posted @ 2023-06-08 14:13  梅落南山  阅读(13)  评论(0编辑  收藏  举报