Java多线程知识点整理。
今天才学习完尚硅谷Java视频_JUC 视频教程_哔哩哔哩 (゜-゜)つロ 干杯~-bilibili,然后结合自己对于Java多线程的理解,汇总了下面的知识点,这是第一篇,后面还要出一篇。
不过这点Java多线程知识远远不够,后面应该在校招之前还要继续看高并发的东西然后再出几篇博客……
一、Java创建线程的四种方式
这里说的是创建线程的四种方式,然后很多教材和博客上会说创建线程的方式有三种,其实创建线程池也是创建了线程,只是不执行任务而已。
1. 继承Thread类实现多线程
核心方法:public void run()
示例代码如下:
1 | public class MyThread extends Thread{ |
2. 实现Runnable接口实现多线程
核心方法:public void run()
示例代码如下:
1 | public class MyThread implements Runnable{ |
java.lang.Thread
和java.lang.Runnable
的异同:
- 通过查阅JDK文档,我们可以看到,
Thread
类也实现了Runnable
接口Thread
类由于Java单继承限制,因此通过继承实现多线程的灵活性较差;而Runnable
接口可以让类实现多个接口,比较灵活- 在实现
Runnable
这种方式中,使用了简单的代理模式:MyThread
类负责业务操作,而Thread
类负责资源调度与线程创建
3. 实现Callable接口实现多线程(jdk1.5以后)
核心方法:public T call()
示例代码如下:
1 | public class MyThread implements Callable<Integer>{ |
java.util.concurrent.Callable
接口和java.lang.Runnable
接口异同
- 实现了
Callable
的对象并不能直接运行,需要以其为参数实现一个java.util.concurrent.FutureTask
对象,然后才可以放到Thread
对象里面执行Callable
支持返回值,泛型为返回值类型,并且可以抛出异常- 接受的返回值通过
FutureTask
对象的get()
方法获得FutureTask
可以用于闭锁
4. 创建线程池
线程池负责线程的使用与调度,但不负责线程内部的业务逻辑,因此创建线程池仅仅是创建了线程,如果要执行相应的业务逻辑,还是需要使用上述三种方式创建线程实现的类。
示例代码如下:
1 | public MyThread{ |
二、Thread类详解
下面来仔细介绍几个Thread
类中的重要方法……
1. 构造方法
1.1 Thread()
构造一个新的Thread对象
1.2 Thread(Runnable target)
构造一个新的Thread对象,并且将Runnable接口参数作为自己的运营对象。即当执行start()
方法后,执行的将是target.run()
方法
1.3 Thread(Runnable target, String name)
构造一个新的Thread对象,实现Thread(Runnable target)
构造方法,并且将这个新的Thread对象的线程名设置为`name
2. static Thread currentThread()
返回当前正在执行的线程对象的引用。
这个静态方法的作用一般是用于在通过实现Runnable
接口或Callable
接口的线程类中,需要使用到当前执行的线程对象的引用。例如获取当前执行的线程的线程名就要在run()
方法中这么写Thread.currentThread().getName()
3. 获取线程信息
3.1 public long getId()
获取线程的标识符。这个线程ID是创建线程时候自动生成的唯一ID,在线程销毁后可以复用。
3.2 public String getName()
获取线程名。线程名可以通过程序自动生成,也可以通过构造方法来指定线程名,还可以通过serName(String name)
来设置线程名
3.3 public int getStackTrace()
获取线程的优先级。线程优先级可以通过setPriority(int newPriority)
来设置优先级(一般为0到10)
线程优先级越高,该线程越可能被分配到CPU资源。
3.4 public boolean isDaemon()
获取这个线程是否为守护线程。可以通过setDaemon(boolean on)
方法来设置该线程是否为守护线程。
4. 线程控制
4.1 public void start()
开始执行线程,Java虚拟机开始调用此线程的public void run()
方法
4.2 public void join()/public void join(long millis)
将该线程加入到主线程,等待最多millis毫秒的时间(默认为无穷),当主线程执行到join()
方法时,主线程会挂起,只有当超时或该线程完成操作后,主线程才会继续往下执行。
下面是个例子,通过例子自行体会其中的奥秘:
1 | public class TestJoin { |
4.3 public static void sleep(long millis)
是当前正在执行的线程停留millis毫秒的时间。
4.4 其他控制线程的方法
Object.wait()
/Object.wait(long timeout)
可以看到,这是Object内置的一个方法,该方法可以导致当前线程等待,直到超时(默认为无穷)或另一个线程调用Object.notify()
方法或Object.notifyAll()
方法。
Object.notify()
/Object.notifyAll()
唤醒当前对象或唤醒全部对象。这两个方法可以配合上面Object.wait()
方法来解决同步问题(又叫等待-唤醒机制)。
例:
1 | public class Clerk { |
三、同步(并发)问题
多线程的同步问题指的是多个线程同时修改一个数据的时候,可能导致的问题
1. 产生同步问题的案例
1 | public class ConcurrencyTest implements Runnable { |
上面这个案例逻辑很简单,就是让程序开辟两个线程同时对cont
进行+1操作5000次,我们预期的结果应该是10000,但是多运行几次我们会发现,结果并不像我们预期的那样,而总是一个小于等于10000的数,而这个数我们一般称为脏数据。
上面这个就是一个典型的多线程同步问题。
2. 解决多线程同步问题-synchronized
对于synchronized
关键字的解释将在[四、2. 理解synchronized
关键字](##2. 理解synchronized
关键字)说明,这里仅仅先展示其用法。
代码修改如下:
1 | public class ConcurrencyTest implements Runnable { |
这个改进的案例展示了synchronized
关键字的两种使用方式:
- 使用在代码块处,括号中的参数为需要同步的对象,案例中需要同步的对象是其本身,因此是
synchronized(this)
- 使用在方法声明处,与访问修饰符、静态修饰符同级,表示对该方法进行同步
3. 解决多线程同步问题-java.util.concurrent.Lock
对于java.util.concurrent.Lock
的解释将在[四、3. 显示锁java.util.concurrent.Lock
](##3. 显示锁java.util.concurrent.Lock
)进行解释,这里仅仅展示它的使用方法。
1 | public class ConcurrencyTest implements Runnable { |
这段代码和使用synchronized
的代码最终效果是一样的,都可以输出10000
,然后Lock
显示锁的一般使用方法就是这样子的,但是要注意,一定要执行unlock()
方法,否则对象会一直持有锁,导致程序阻塞。
4. 剖析产生并发问题的原因
首先我们先理解一个概念叫内存可见性:当多个线程操作共享数据时候,彼此不可见
大概意思就是在多线程项目中,我们的内存可以简单的理解为主存和线程缓存,而线程的所有操作都是先从主存中获取变量的当前值,然后在自己的线程缓存中进行的,如下图:
在案例中,线程1和线程2每次执行运算操作都要先从主存中获取当前的x,然后再对x做一个+1操作,然后把结果返回给主存;但是由于线程是并行执行的,因此很可能出现线程1还没有将自己的运算结果返回给主存,然后线程2就读取了主存中的x值,这样一来,线程2就相当于做了一次重复的运算,并且这个结果还会返回给主存导致后面的运算全部出错……由此,产生了脏数据。
四、锁
1. 什么是锁
在Java多线程编程中,锁可以理解为对象拥有的一种资源或标志。通常情况下,持有锁的对象只能有一个线程对齐进行操作,而其他线程尝试访问的时候会被阻止。
对比上面的剖析,我们可以认为,每次某个线程需要操作数据的时候,一定会确认其他线程已经完成了对这个数据的操作,并且成功的返回到了主存中,此时该线程才会从主存中读取数据然后进行计算。
Java中的锁大概有两类:
synchronized
关键字java.util.concurrent
包下面的锁
2. 理解synchronized
关键字
这里参考了《Java编程思想》p677
当任务要执行被
synchronized
关键字保护的代码片段的时候,它将检查锁是否可用,然后获得锁,执行代码,释放锁。所有对象都自动含有单一的锁(也称为监视器)。当在对象上调用包含
synchronized
关键字的方法的时候,该对象会被加锁,这个时候该对象上的其他被synchronized
关键字修饰的方法需要等待上一个方法执行完毕并释放锁后才可以被执行。
上面第一段说的是锁的作用;第二段表名锁是针对整个对象而言的,而不是针对某一个方法而言的。
示例代码如下所示:
1 | public class ConcurrencyTest implements Runnable { |
对于上面的代码中的getA()
和getB()
方法有如下三种情况:
- 两个方法同时没有添加
synchronized
关键字,输出的a和b是乱序的,证明在执行任意方法的时候,另一个方法可以同时执行 - 两个方法同时添加
synchronized
关键字,输出的是整齐的字符串,证明在执行任一方法的时候,另一个方法都必须等待上一个方法执行完才可以执行 - 其中一个方法添加
synchronized
关键字,被修饰的方法输出的是整齐的字符串,但是其开头和结尾会插入另一个方法的字符,这个情况就可以证明前面说的第二段内容
刚才所说,synchronized
是给对象加锁,那么修饰方法的synchronized
关键字就可以理解为下面这一段代码:
1 | synchronized(this){ |
有时候,方法会被static
修饰,如果一个方法同时被static synchronized
两个关键字修饰,那么这个方法就被称为静态同步方法,此时,synchronized
关键字就不是给this
加锁了,而是给这个类的Class
对象加锁,类似于下面的代码:
1 | synchronized(Object.class){ |
3. 显示锁java.util.concurrent.Lock
显示锁对应的是隐式锁,隐式锁就是通过synchronized
修饰的方法或者对象,其为对象加的锁我们成为隐式锁,而使用java.util.concurrent.Lock
对象来给对象加锁,我们称为显示锁。
Lock
是一个接口,其接口中定义了一下几个方法:
void lock()
获得锁Condition newCondition()
返回一个新的Condition绑定到Lock实例,对于Condition下面会进行讲解boolean tryLock()
如果锁没有被其他线程占有的时候,则该方法返回true
且和lock()
方法作用相同,如果锁被其他线程占有,则返回false
,然后不再等待释放锁,而是直接跳过执行;一般要和if…else…配合使用void unLock()
释放锁
使用显示锁的一大好处就是更加灵活,在使用的时候,只要在需要加锁的代码段前面使用lock()
方法获取锁,然后在需要加锁的代码段后面使用unlock()
释放锁即可,而synchorinzed
关键字只能用在一个 代码块或者一个方法上,相比之下灵活性较弱。
使用Lock
的方式很简单,只需要在类中定义一个Lock
对象即可:Lock lock=new ReentranLock()
,然后根据上面的描述使用lock
对象即可。
3.1 java.concurrent.locks.Condition
类
上面所讲,我们可以通过Lock对象的newCondition()
方法得到一个新的Condition
对象绑定到Lock
对象。
这个Condition
是什么呢?这个类大概就是实现了对于Lock接口的Object.wait()
方法和Object.notify()/Object.notifyAll()
方法。他有如下几个重要的方法:
public void awite()
导致当前线程暂停,类似于Object.wait()
方法public void signal()
唤醒当前线程,类似于Object.notify()
方法public void signalAll()
唤醒所有线程,类似于Object.notifyAll()
方法
五、关键字volatile
1. volatile
关键字的使用
在了解volatile
关键字之前,我们先看一下下面这个例子:
1 | public class TestVolatile { |
上面这个程序,我们预计的输出应该是
1 | flag = true |
但是,我们将这段代码放到编译器中执行,发现程序只输出flag = true
,然后主线程就阻塞了……
这是为什么呢?我们来分析一下上面案例中的代码:
- 程序一共有两个线程:主线程和子线程
- 子线程中有修改
flag
的操作,即写入数据;而主线程中有读取`flag的操作,即读取数据- 子线程中延迟了0.2s然后才对flag进行修改
- 主线程中读取
flag
的操作卸载了while(true)
中,即死循环中,除非flag
为真才停止
然后我们利用前面第三节剖析产生并发问题的原因中所介绍的,主线程也是线程,主线程读取数据也应该从主存中进行读取,然后再进行操作。不过呢,由于while的执行效率极其之高,导致主线程中的死循环代码无法读取到更新后的flag
值(可以通过在while(true)里面添加一个1ms的延迟来观看效果)。
至于解决办法,确实可以使用我们上面的synchronized
关键字或Lock
锁使操作同步,但是这里我们换一种实现方式:使用volatile
关键字修饰变量。
解决方式为:将上面第19行代码,即声明flag
处的代码改为:private volatile boolean flag = false
添加了volatile
关键字修饰后的flag
变量,对其的写操作会比对其的读操作先发生参考《深入理解Java虚拟机》p376,由于此案例两个线程分别包含了对flag
的读操作和写操作,那么对于volatile
的规则则是允许的。
2. volatile
与synchronized
的区别
volatile
是一种较为轻量级的同步策略,它提供的是一种非阻塞同步,而synchronized
以及锁都是一种阻塞同步volatile
不具备“互斥性”(互斥性:当一个线程占有锁的时候,其他线程不可以访问锁住的数据)volatile
不能保证变量的“原子性”
前两点我们可以通过上面的案例很好理解。首先,阻塞同步的性能在于处理器进行线程阻塞或唤醒线程带来的性能问题,而volatile
并不会对线程进行阻塞或唤醒(或者说是需要的时候再进行这些操作);其次,“互斥性”等同于“互斥同步”或“阻塞同步”,所以volatile
不具备“互斥性”
下面我们着重对第三点进行讲解。
2.1 原子性
首先再看个案例:
1 | public class ConcurrencyTest implements Runnable { |
这个案例就是之前的产生同步问题的案例,不过我们为那个共享的变量添加了一个刚刚学习的volatile
关键字,但是我们运行之后并不能得到我们期待的结果10000。
由此,我们引出了原子性问题:
i++ 原子性问题:i++的操作实际上分为三步,即读-改-写,翻译成代码如下:
int temp=i;i=temp+1;return i;
。因为i++的操作分成了三个步骤,且中间有个临时变量生成,因此我们不能只使用volatile
修饰变量来保证i++的正常执行。
2.2 原子变量
jdk1.5以后,java.util.concurrent.atomic包下提供了大量原子变量,这些原子变量就是专门为了解决原子性问题的。
首先我们来看看原子变量都有哪些:
AtomicBoolean
AtomicInteger
AtomicLong
AtomicReference
AtomicLongArray
AtomicReferenceArray
AtomicIntegerFieldUpdater
AtomicReferenceFieldUpdater
LongAdder
LongAccumulator
DoubleAdder
DoubleAccumulator
这些原子变量几乎包含了所有我们在实际开发中要使用到的变量类型。
然后是这些原子变量的使用:
1 | public class ConcurrencyTest implements Runnable { |
对于这些原子变量的使用,我们可以参考jdk文档。
2.3 原子变量实现原理(CAS算法)
原子变量的实现原理主要是CAS(Compare-And-Swap)算法。下面就来说说到底什么是CAS算法
CAS定义了三个操作数:1. 内存值V 2. 预估值B 3. 更新值A
在执行更新操作的时候,当且仅当V==A时,才会使得V==B,否则不进行任何操作,但是无论是否更新了V的值,都会返回V的旧值
这个CAS算法的简单实现我们可以参考下面的代码:
1 | class CompareAndSwap { |