Java基础(多线程篇)

前言

没想到多线程的内容一拖再拖拖了这么久。虽然之前看过几次,但是还是赶在面试之前将这一块内容中面试经常会问的内容整理出来,做一个总结吧。
主要内容分为关键字、锁、ThreadLocal、原子类、AQS和线程池。

缓存一致性

程序在运行的时候会把运算需要的数据从主存中复制一份到CPU的高速缓存中,运算完成之后再写回内存,这就导致了多个线程同时进行运算的时候会导致最后的运算结果出错。
举个例子,比如常用的i+1,假设i初始值为0,线程1读取0到缓存中,然后加1,与此同时线程2读取0到缓存中然后加1,最后写回内存的时候,线程1把0修改为1,然后线程2又会将1修改为1,这就导致了看上去进行了两次+1操作但是最后的值依然是1。

volatile关键字

violate关键字有两个特性:1.可见性 2.有序性。

可见性的意思是维护了不同线程对这个变量的可见性,使用 volatile关键字可以强制线程将修改的值立刻写入内存。同时会告知其他线程,他们缓存中的变量已经被修改,需要重新去内存中读取。

有序性的意思是禁止编译器对指令重排序,编译器在生成字节码会对violate关键字生成一个内存屏障,内存屏障之前的指令必须执行,内存屏障之后的指令必须后执行。
所以说violate的实质实质就是在读指令前生成一个读屏障,查看这个变量是否有效,在写指令后面增加一个写屏障,将更新的值立马写回内存中。

Synchronized关键字

synchronized关键字可以保证被他修饰的方法或者代码块在任意时刻只有一个线程执行。
synchronized关键字可以修饰实例方法、静态方法和代码块。

synchronized修饰代码块的时候,会使用monitorentermonitorexit指令,在开始的时候,monitorenter指令会尝试获取锁也就是monitor(monitor存在于每个对象的头部),在结束的时候,会释放当前锁。

synchronized修饰方法的时候采用的是ACC_SYNCHRONIZED标识,标识这是一个同步方法。

在JDK1.6之后,synchronized的性能得到了优化,加入了偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少开销。

对象头中的锁状态一共有4种:无锁、偏向锁、轻量级锁和重量级锁。
对象头中主要包括两部分数据:Mark Word(标记字段)和Klass Pointer(类型指针)。
Mark Word默认存储一些与对象自身定义无关的信息,比如HashCode、GC年龄和锁标志位,长度不固定。
Klass Pointer用于指元数据类型的指针,用于确定这个对象是哪个类的实例。
刚才我们说到,synchronized是通过获取monitor来进行加锁的,而monitor又是通过Mutex Lock来实现的,这种实现方式被称为重量级锁。
但是1.6之后对其进行了优化,增加了偏向锁和轻量级锁。所以现在对象会根据自身的情况,逐渐将锁膨胀,这个过程是不可逆的。

偏向锁

偏向锁的意思就是对象记录获取锁的线程ID,然后每次该线程想要获取锁的时候判断其是否是记录的ID,如果是的话就不需要进行同步,直接进入锁。

当偏向锁遇到其他线程尝试竞争的时候,持有偏向锁的进程才会主动释放。然后会进行偏向锁的撤销,撤销在全局安全点的时候进行的,撤销之后锁会变成无锁状态或者轻量级锁状态。

轻量级锁

是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。

拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。

如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

重量级锁

重量级锁相比较轻量级锁的特点就在于,等待锁的线程会进入阻塞状态而非自旋状态。

除此之外,锁还可以分为公平锁和非公平锁、可重入锁和非可重入锁以及独享锁和共享锁,这些大多都是用AQS来实现的,这个放到后面说。

ThreadLocal

ThreadLocal是Java中提供的一个类,这个类让每个线程都获取一个自己的副本,而不是和其他线程共享。

private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(()->new SimpleDateFormat("YYYYMMDD HHMM"));

上面的代码就声明了一个ThreadLocal实例用于保存时间信息。

ThreadLocal其实是放在线程自身内部的,每个线程都保存有一个ThreadLocalMap类型的成员threadLocals,默认都是null,等到线程调用ThreadLocalset()或者get()方法的时候才会进行实例化。
而在set()方法中,ThreadLocal也是使用getMap方法获取了线程内部的threadLocals,然后在这个map中放入键为ThreadLocal对象的键值对,当然如果线程还不存在这个map的话则会先创建。

由于ThreadLocalMap和一般的Map一样,key为弱引用,而value为强引用,所以一旦Map失去了外部引用,会导致key被回收而value不被回收,从而导致内存泄漏。

线程池

原理

当创建一个线程池的时候,此时线程池中线程数为0。
然后我们此时提交了一个任务:

  1. 如果线程池中的线程数小于设定的核心池大小(corePoolSize),那么即使线程池中存在空闲线程也要创建一个线程执行任务。
  2. 如果线程池的线程数大于等于核心池大小,并且缓冲队列workQueue未满,那么任务会被放入缓冲队列。
  3. 如果线程核心池已满,并且缓冲队列也满了,但是线程数小于最大线程数(maximumPoolSize),那么会直接创建一个线程来执行任务。
  4. 如果线程数已经达到了最大线程数,那么会根据设定的拒绝策略来处理任务。
  5. 当线程完成任务之后,如果线程数小于maximumPoolSize,但是大于corePoolSize,那么该线程会停留一段时间(keepAliveTime),然后进行销毁,否则加入线程池。

拒绝策略一共有4种:

  • AbortPolicy:直接抛出异常
  • DiscardPolicy:直接丢弃任务
  • DiscardOldestPolicy:丢弃缓冲队列队伍头的任务。
  • CallerRunsPolicy:不用线程池中的线程执行,使用调用者所在线程执行。

实现

实现线程是主要有两个要点:

  • 线程类
  • 线程池类

我们首先自己定义一个继承了Runnable接口的线程任务类:

public class MyRunnable implements Runable{
	private String commandl
	public MyRunnable(String s){
		this.command = s;
	}
	//对于要执行的任务重载run方法。
	@Override
	public void run(){
	System.out.println(Thread,currentThread().getName()+"Start"+new Date());
	processCommand();
	System.out.println(Thread,currentThread().getName()+"end"+new Date());
	}
	private void processCommand(){
		try{
			Thread.sleep(5000);
		}catch (InterruptedException e) {
            e.printStackTrace();
        }
	@Override
	public String toString(){
		return this.command;
	}
}

然后定义一个实例化了ThreadPoolExecutor的类:

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){
		TheadPoolExecutor 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++){
			Runable worker = new MyRunnable(""+i);
			executor.execute(worker);
		}
		executor.shutdown();
		while(!executor.isTerminated()){
		}
	}
}

Atomic原子类

原子,在多线程中指的就是一个不可中断的操作。原子类指的就是具有这种操作特征的类。

JUC包中所有的原子类都存放在java.util.concurrent.atomic下。
JUC包中一共有4个原子类:
基本类型(使用原子操作更新基本类型):

  • AtomicInteger:整型原子类
  • AtomicLong:长整形原子类
  • AtomicBoolean:布尔型原子类

数组类型(使用原子操作更新数组中的某个元素):

  • AtomicIntegerArray:整型数组原子类
  • AtomicLongArray:长整型数组原子类
  • AtomicReferenceArray:引用类型数组原子类

引用类型:

  • AtomicReference:引用类型原子类
  • AtomicStampedReferenct:原子更新带有版本号的引用,可以用于解决CAS的ABA问题。
  • AtomicMarkbaleReferenc:原子更新带有标记位的引用类型。

对象的属性修改类型:

  • AtomicIntegerFieldUpdater:原子更新整型字段的更新器
  • AtomicLongFieldUpdater:原子更新长整形的更新器

原理

AtomicInteger类主要利用CAS+volatile和native方法来保证原子操作,避免synchronized带来的高开销。
CAS的原理是拿期望的值和原来的值进行一个比较,如果相同则更新。AtomicInteger通过Unsafe类的objectFieldOffset()方法来取得原来的值的内存地址,然后使用volatile修饰value值,确保所有线程可见。

AQS

AQS,全称为AbstractQueuedSynchroinizer,从名字上来看就可以看出是一个通过虚拟队列来实现同步。
AQS的核心思想就是:如果被请求的共享资源闲置,那么将当前请求的资源线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,则需要一个线程阻塞等待以及被唤醒时候的锁分配机制,这个机制AQS是使用CLH锁队列实现的。
CHL:是一个虚拟的双向队列,AQS将每条请求共享资源的线程分装成一个CLH锁队列的一个节点来实现锁的分配。

AQS定义资源有两种共享方式:

  • Exclusive(独占式):只有一个线程能够执行,比如ReentrantLock。
  • Share(共享式):可以多个线程执行,比如Semaphore/CountDownLatch。

AQS组件总结

  • Semaphore(信号量):允许多个线程同时访问。
  • CountDownLatch(计时器):一个同步工具类,用来协调多个线程之间的同步。
  • CyclicBarrier(循环栅栏):让一组线程到达这个栅栏之后被阻塞,等到所有线程都到达之后才开始继续任务。

后记

大致总结了一下,也不是很详细,具体的部分等到后面继续看源码吧。
刚才看了一会面经,发现多线程这一块有几个问题非常喜欢问。

  1. 线程池有几种实现方式。
  2. synchronized关键字和lock的区别
  3. volatile的原理

所以这一部分还是明天继续深入研究吧。


在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_33241802/article/details/106986208