多线程
计算机在同一时间可以执行多个线程
并行
多个事情在同一时间点内发生,并行的发生是不会抢占资源的
并发
多个事情在一段时间内同时发生,并发的产生会抢占资源
多线程的好处
如果为单线程计算机一次只能处理一个线程,那么当处理的线程需要等待获取其他资源时,CPU就会处于空闲状态,这时CPU就可以执行其他的线程,从而提高CPU的利用率。由于一个程序的执行是由多个线程组成的,相比于单线程也能提高程序的执行速度。
并不是只有当正在执行的线程需要获取其他资源时线程才能切换,由于是多线程,为了满足所有的线程都能被平等地执行,于是采用的是分时的线程调度机制(当一个线程被执行的时间到达指定的时间后,就会进行等待,而CPU就会去执行其他的进程),于是会引发如下的问题。
多线程引发的问题
共享资源的安全性问题,由于计算机在同一时间执行了多个线程,当多个线程对同一资源进行修改时(访问同一资源),会导致变量出现问题。
例如:
JVM的主内存存在一个变量int a = 0;此时有两个线程A和B,都要对变量a进行加一操作,A线程先对变量a进行加一操作,此时线程A还没有对变量a的值进行覆盖,但是时间片进行了切换,此时如果线程B读取主内存中的变量a,那么变量a的旧值就会被线程B获取,线程A和线程B最后进行结果覆盖,我们就会发现此时变量a的结果只是加一了一次,与正确的结果不一样。
上下文切换问题
如今我们使用的计算机都是多线程的,所以我们才可以边听音乐边打游戏,这是由于播放音乐线程和游戏运行线程在非常短的时间内交替执行,时间短到我们无法察觉,因此我们才会觉得我们是在边听音乐边打游戏,但是线程进行上下文切换时也需要耗费一定的资源,所以当执行的线程过多时,在相同的一段时间内同一线程被执行的次数就会变少,也就会导致我们觉得计算机变卡了。
并发编程
为了解决多线程引发的共享资源的安全性问题(多个线程同一个时间点访问同一资源),我们将同时访问同一资源的线程使用编程进行管理,转换为线程依次对同一资源进行访问,这就是并发编程。
并发编程如何实现?
synchronized关键字
public class Cat {
public static int age = 0;
public static Object lock = new Object();
public synchronized void set() {// synchronized关键字修饰的方法不会同时被多个线程执行
age++;
}
public void play() {// synchronized关键字修饰的代码块不会同时被多个线程执行
synchronized (lock) {// lock的对象头中记录的是锁的状态,为了保证不同线程获取的锁对象为同一个,
age++;
}
}
}
ReentrantLock类
Java内存模型(Java Memory Model)JMM
JMM指的是Java虚拟机在执行线程时内存的工作过程
Java虚拟机将所有的变量都存储在主内存当中,当一个线程需要对主内存当中的变量进行操作时,会先将主内存中的变量复制一份到线程中的工作内存当中(每一个线程都会有一个属于自己的工作内存,是线程私有的),然后对工作内存中的变量副本进行操作,最后用操作完成后的变量副本对主内存中的变量进行覆盖,如果只是查询操作就不需要对主内存中的变量进行覆盖。
Java内存模型存在的问题
不可见性
由于线程在对变量进行操作时是在线程自己私有的工作内存中,因此一个线程在对变量进行操作时,另一个线程是不知道的,所以另一个线程无法准确并适时地获取当前真正的变量,就有可能导致对变量操作后的结果存在问题。
无序性
当线程中正在执行的代码需要等待获取其他资源时,为了提高CPU的运行效率,CPU就会跳过当前代码而执行后面可以执行的代码,这种情况在大多数情况下都是没有问题的,但是毕竟运行的顺序和程序中代码写的不一样,难免会出现一些问题。
非原子性
当一个线程的时间片结束时,会进行上下文的切换,由于Java虚拟机中只能保证机器指令的执行是原子性的,而正常的一条对数据进行更改的操作,例如count++,就需要3条机器指令来完成:
指令1:获取主内存中的变量count
指令2:在工作内存中执行count++操作
指令3:将结果赋值给主内存中
只要其中一条指令被分开执行,都会导致主内存中的数据出现问题
volatile关键字
用于修饰类中的成员变量(静态和非静态),保证了这个变量在被不同线程操作时的可见性,一个线程在改变了变量的值之后,对于另一个线程来说是立即可见的,可以禁止指令的重排序,volatile不能保证对变量操作的原子性。
volatile底层实现原理
使用Memory Barrier(内存屏障)
如何保证原子性?
需要保证同一个时刻多个线程在访问同一个资源时,只有一个线程可以进行访问
锁
synchronized关键字
synchronized关键字用于修饰方法或者代码块,修饰的方法以及代码块中的代码在同一时刻只能有一个线程进行访问,synchronized关键字可以保证原子性,同时由于关键字将代码块中的代码变为了一个整体,所以也能够保证可见性和有序性
原子变量
原子类
CAS(Compare And Swap):比较并交换,是乐观锁的一种实现方式,采用的是自旋锁的思想
CAS中包含了三个操作数内存值、预估值、更新值,当一个线程对主内存中的变量操作完成后,需要将结果赋值给主内存时,需要对内存值和预估值进行判断,内存值就是当前主内存中变量的值,预估值就是线程将主内存中变量的值复制到工作内存时的值,如果这两个值相等,就认为这个变量在线程进行结果计算时并没有发生改变,这时可以直接将得到的结果赋值给主内存中的变量,如果这两个值不相等,那么就认为,线程在对变量进行结果计算的过程中又有其他的线程对主内存中的变量进行了修改,这时这个线程就需要重新获取新的内存值,并重新进行一次结果计算,直到内存值和预估值相等。
CAS的缺点:
CAS采用自旋锁的方式(无锁),不断地重复就绪执行的状态,直到内存值和预估值相等,会占用一定的CPU,所以如果这样的线程过多的时候,就会导致CPU开销过大。
ABA问题
虽然说CAS可以通过比较内存值和预估值是否相等来判断变量是否被更改,但是有一种情况即使内存值和预估值相等的情况下,变量依然被修改了,只不过进行多次修改后的内存值仍然与预估值是相等的
解决方法
给变量添加版本号,每次在对变量进行修改时改变版本号,虽然有可能内存值和预估值比较是相等的,但是如果过程中发生了修改,那么版本号也是不同的,可以通过比较内存值和预估值,以及版本号来确定变量在内存中是否更新过。
Java中锁的分类
锁的分类可以根据特性、设计、状态进行分类,并不都是具体的实现
乐观锁/悲观锁
根据如何看待线程并发问题进行分类
乐观锁
乐观锁认为当一个线程访问数据时,另一个线程不会对这个数据进行修改,所以只会在更新数据时进行判断,如果访问数据的过程中数据修改了,那么就会不断尝试更新数据,如果数据没有修改,就直接执行赋值操作。乐观锁没有加锁。乐观锁适合读操作较多的场景,不加锁性能会提升。
悲观锁
悲观锁认为当一个线程访问数据时,另一个线程一定会对这个数据进行修改,即使没有更改数据,也会认为进行了修改。因此当多个线程对同一个数据进行访问时,会进行加锁,使同一时刻只能有一个线程访问同一资源。悲观锁适合写操作较多的场景。
可重入锁
同一个线程获取了外层方法的锁时,在进入内层方法时会自动获取锁(前提:获取的锁对象为同一个)。获取一个类中的不同方法时,synchronized和ReentrantLock获取的是同一把锁。
代码举例:
public static synchronized void testA() {
System.out.println(“A方法”);
testB();
}
public static synchronized void testB() {
System.out.println(“B方法”);
}
public static void main(String[] args) {
new Thread(() -> {
testA();
}).start();
}
synchronized锁是可重入锁,如果不是可重入锁,那么就会产生死锁。
读写锁
读写锁读读不互斥,读写互斥,写写互斥,防止一个线程在修改数据时另一个线程读取到了更改之前数据。
ReentrantReadWriteLock是一种读写锁的实现。
分段锁
分段锁简单来说就是将锁的粒度细化,对数据进行分段,并给每个分段加锁,提高并发效率。
Hashtable集合是线程安全的,使用的是synchronized关键字对每个集合中的方法都进行加锁,这样虽然是线程安全的但是同时也会降低并发效率,为了提高集合的并发效率,于是采用了分段锁,将锁的粒度进行细分,Hashtable底层实现是哈希表+链表/红黑树,并不是每次并发访问集合都会对哈希表中同一个位置进行访问,所以对哈希表的每一个位置加锁,保证同一时刻不会对哈希表中同一个位置的元素进行访问。
自旋锁
线程在获取锁失败后,不断循环尝试获取锁,不断尝试过程中该线程不会被切换,这种情况只适合低并发的场景,因为自旋锁会占用CPU的资源,如果并发数量过多,会导致CPU占用过大。自旋锁适合加锁时间(获取锁的线程的执行时间)非常短的场景,这样不断尝试获取锁的线程的等待时间也会很短,相比于将线程
共享锁/独占锁
共享锁:锁对象可以被多个线程持有,读锁就是共享锁
独占锁:锁对象只能被同一线程持有,写锁就是独享锁
共享锁可以提高对同一个数据的并发读操作的效率
公平锁/非公平锁
非公平锁:多个线程谁先获取锁对象,谁就可以执行
公平锁:多个线程按照先来先服务的原则,依次获取锁对象
synchronized锁是非公平锁
ReentrantLock锁默认是非公平的,也可以实现公平锁
ReentrantLock底层实现类AbstractQueuedSynchronizer(AQS)中存在队列,将没有获取锁对象的线程存放入队列中,来实现公平锁。
偏向锁/轻量级锁/重量级锁(锁的状态)
每一个对象都会有对象头,锁的状态就存放在对象头中的Mark Word区域中
偏向锁
锁的状态一开始是无锁状态(没有一个线程访问同步代码块),当同步代码块一直被同一个线程访问时,锁的状态就会变为偏向锁的状态,这时锁对象会将该线程的id记录在对象头中,降低之后再次获取锁对象的成本,提高运行效率。
轻量级锁
当锁是偏向锁的状态时,其他线程获取不到锁对象,此时锁状态就会升级为轻量级锁,此时访问不到锁对象的线程会采用自旋锁的方式不断尝试获取锁对象。
重量级锁
当锁是轻量级锁的状态时,采用自旋锁方式获取锁对象的线程,经过一定的自旋次数仍然没有获取到锁对象,就会进入阻塞状态,此时锁状态上升位重量级锁,进入阻塞状态的线程只能等待CPU的调度。
对象结构
对象在Java虚拟机内存中有三部分区域:对象头、实例数据和对齐填充
对象头中有一块区域Mark Word用于存放对象运行时的数据,如HashCode、经历垃圾回收的次数、锁状态标志(用于记录锁对象是否正在被线程使用)、线程持有的锁状态(偏向、轻量级、重量级)、偏向锁的线程id
Synchronized锁实现
synchronized关键字用于修饰同步代码块和同步方法,同步方法和同步代码块都是通过moniterenter和moniterexit指令实现,同步方法在调用时会有一个ACC_SYNCHRONIZED标识,用于说明该方法为同步方法,所以在方法开始执行时执行moniterenter指令,当方法执行完成后执行moniterexit指令。同步代码块没有标识,只是会在进入同步代码块之前指令moniterenter指令,在同步代码块执行结束时执行moniterexit指令。
在执行moniterenter指令时,线程会尝试获取该同步代码块或同步方法的锁对象,如果获取成功,就将锁对象的计数器加一,在执行moniterexit指令时,会将锁对象的计数器减一,当锁对象的计数器为0时,该锁对象就会被释放,也就是说同步代码块或同步方法中的内容已经执行完成。
AQS(AbstractQueuedSynchronizer)
ReentrantLock锁、ReentrantReadWriteLock锁都是基于AQS抽象类中的方法实现的,AQS位于java.util.concurrent.locks包中,也就是说AQS是解决线程安全问题的锁的实现类,AQS是抽象类但是其中没有抽象方法。
AQS类中有一个int类型成员变量state,用于线程判断锁对象是否正在被使用,默认情况下state的值为0,当有线程正在使用锁对象时state的值变为1。使用volatile关键字修饰state变量可以确保可见性和有序性,所以为了确保对变量修改的原子性,AQS类中提供了特定的原子性的方法,保证对变量的操作是线程安全的。
当多个线程获取锁对象时,AQS会将获取不到锁对象的线程存放到队列当中,队列底层是由双向链表实现,链表的每一个节点(Node:AQS的内部类)封装着一个线程,链表的头部为获取到锁对象的线程。
acquire()方法
用于获取锁对象
方法中的第一步调用tryAcquire(int age)方法尝试获取锁对象,如果获取成功返回true,acquire()方法执行完毕,否则返回false,于是尝试获取锁对象的线程就会被封装到Node节点中,并插入到双向链表的尾部。
AQS的锁存在独占锁和共享锁
独占锁:ReentrantLock锁就是独占锁
共享锁:ReentrantReadWriteLock锁中的读锁就是共享锁
ReentrantLock锁的实现
ReentrantLock锁中又分为公平锁和非公平锁,他们的区别在于公平锁是当线程获取锁对象时,直接将线程放入队列中进行等待,而非公平锁时当线程获取锁对象时,会先尝试获取锁对象,如果尝试获取不到才会将线程放入队列中进行等待。
如下为公平锁和非公平锁获取锁的实现代码
final void lock() {// 公平锁获取锁对象
acquire(1);// 排队
}
final void lock() {// 非公平锁获取锁对象
if (compareAndSetState(0, 1))// 先尝试获取锁对象,底层使用AQS实现,根据变量state判断是否可以获取锁对象
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);// 排队
}
为什么公平锁和非公平锁都需要将线程进入等待队列中?
进入等待队列中的线程会被阻塞,当锁释放后,系统会对阻塞的线程进行唤醒,使线程获取锁对象,如果阻塞的线程没有进入等待队列,系统就会随机唤醒阻塞线程,这样有可能导致有些阻塞的线程永远不会被唤醒。
ReentrantLock:悲观锁、公平锁(可以实现非公平锁)、互斥锁、可重入锁
Synchronized:悲观锁、非公平锁、互斥锁、可重入锁
JUC常用类(Java.util.concurrent)
ConcurrentHashMap
ConcurrentHashMap和HashMap一样都是线程安全的,但是ConcurrentHashMap的效率高于HashMap。原因是因为ConcurrentHashMap采用分段锁的思想,降低锁的粒度,提高了执行效率。
HashMap在方法中使用synchronized关键字,使方法变为同步方法,多个线程不能同时调用同一个方法,而ConcurrentHashMap使用分段锁,将锁的粒度变为链表或者红黑树,这样多个线程在同一时刻就可以调用同一个方法,提高了执行效率。
JDK1.8之后放弃分段锁的原因?
分段锁是给链表或者红黑树加锁,当集合中的元素越来越多时,会导致链表变得越来越长,进而会使锁的粒度变得越来越大,从而导致分段锁的性能变低。
分段锁是给Hash表中的每一个节点都添加锁,不管该索引位置下是否存在元素都会存在锁,有可能该索引下一直没有元素,导致内存空间的浪费。
JDK1.8之后放弃了分段锁而使用粒度更小的Node锁,在添加元素时首先判断该索引位置下是否存在元素,如果不存在元素,就直接添加元素,如果存在元素,就给该位置下的Node头节点添加Node锁(synchronized锁),确保添加时的原子性。
ConcurrentHashMap不支持存储null键和null值的原因?
不支持null值是为了消除歧义,如果键值对的值为null,那么在使用键获取对应的值时,如果返回值为null,我们不清楚是输入键所对应的值为null,还是输入键因为在集合中不存在所以返回null。
CopyOnWriteArrayList
CopyOnWriteArrayList相比于ArrayList是线程安全的,虽然Vector同样也是线程安全的,但是CopyOnWriteArrayList的效率要高于Vector。
Vector给每一个方法都添加了synchronized关键字,同一时刻只能有一个线程访问同一个方法,CopyOnWriteArrayList允许同一时刻有多个线程进行读操作,但是同一时刻只能有一个线程进行写操作,CopyOnWriteArrayList在进行写操作时,会先对集合中的底层数组进行复制一份,并对复制出来的数组进行数据修改操作,当修改完成后再使用修改完成后的数组对原数组进行覆盖,这样在多线程的情况下,当一个线程对集合进行写操作的同时,其他线程还能对集合进行读操作,提高了运行效率。CopyOnWriteArrayList使用ReentrantLock锁实现对集合写操作的并发执行。
CopyOnWriteArraySet
CopyOnWriteArraySet是基于CopyOnWriteArrayList实现的,不能存储重复元素。
CountDownLatch
CountDownLatch允许一个线程在等待其他线程执行完毕之后在去执行,在创建对象时指定参数用于表示线程等待其他线程的数量。