多线程基础概念
进程与线程
进程:进程是程序的执行过程(具有动态性),持有资源(共享内存,共享文件)和线程 (在一台电脑上使用idea和微信两个程序的过程叫进程)
线程 :系统中的最小执行单元。一个进程中有多个线程,每条线程并行执行不同的任务。
线程共享进程的资源。
(主要解决处理器单元内多个线程执行的问题,它可以显著减少处理器单元的闲置时间,增加处理器单元的吞吐能力。)
(一个班级中的多个学生,这些学生使用自己的座椅进行学习)
为什么要用多线程?
①、为了更好的利用cpu的资源,如果只有一个线程,则第二个任务必须等到第一个任务结束后才能进行,如果使用多线程则在主线程执行任务的同时可以执行其他任务,而不需要等待;
②、进程之间不能共享数据,线程可以;
③、系统创建进程需要为该进程重新分配系统资源,创建线程代价比较小;
④、防止阻塞。多条线程同时运行,哪怕一条线程的代码执行读取数据阻塞,也不会影响其它任务的执行。
线程的生命周期
线程是一个动态执行的过程,它也有一个从产生到死亡的过程。
新建状态:
使用 new 关键字和 Thread 类或其子类建立一个线程对象后,该线程对象就处于新建状态。它保持这个状态直到程序 start() 这个线程。
就绪状态:
当线程对象调用了start()方法之后,该线程就进入就绪状态。就绪状态的线程处于就绪队列中,要等待JVM里线程调度器的调度。
运行状态:
如果就绪状态的线程获取 CPU 资源,就可以执行 run(),此时线程便处于运行状态。处于运行状态的线程最为复杂,它可以变为阻塞状态、就绪状态和死亡状态。
阻塞状态:
如果一个线程执行了sleep(睡眠)、suspend(挂起)等方法,失去所占用资源之后,该线程就从运行状态进入阻塞状态。在睡眠时间已到或获得设备资源后可以重新进入就绪状态。
可以分为三种:
等待阻塞:运行状态中的线程执行 wait() 方法,使线程进入到等待阻塞状态。
同步阻塞:线程在获取 synchronized 同步锁失败(因为同步锁被其他线程占用)。
其他阻塞:通过调用线程的 sleep() 或 join() 发出了 I/O 请求时,线程就会进入到阻塞状态。当sleep() 状态超时,join() 等待线程终止或超时,或者 I/O 处理完毕,线程重新转入就绪状态。
死亡状态:
一个运行状态的线程完成任务或者其他终止条件发生时,该线程就切换到终止状态。
start和run的区别
start() :
它的作用是启动一个新线程。
通过start()方法来启动的新线程,处于就绪(可运行)状态,并没有运行,一旦得到cpu时间片,就开始执行相应线程的run()方法,这里方法run()称为线程体,它包含了要执行的这个线程的内容,run方法运行结束,此线程随即终止。start()不能被重复调用。用start方法来启动线程,真正实现了多线程运行,即无需等待某个线程的run方法体代码执行完毕就直接继续执行下面的代码。这里无需等待run方法执行完毕,即可继续执行下面的代码,即进行了线程切换。
run() :
run()就和普通的成员方法一样,可以被重复调用。
如果直接调用run方法,并不会启动新线程!程序中依然只有主线程这一个线程,其程序执行路径还是只有一条,还是要顺序执行,还是要等待run方法体执行完毕后才可继续执行下面的代码,这样就没有达到多线程的目的。
总结:调用start方法方可启动线程,而run方法只是thread的一个普通方法调用,还是在主线程里执行。
public class Test {
static void pong(){
System.out.print("pong");
}
public static void main(String[] args) {
Thread t=new Thread(){
public void run(){
pong();
}
};
t.run();
System.out.print("ping");
}
}
运行结果:
pongping
start() 可以启动一个新线程,run()不能
start()不能被重复调用,run()可以
start()中的run代码可以不执行完就继续执行下面的代码,即进行了线程切换。直接调用run方法必须等待其代码全部执行完才能继续执行下面的代码。
start() 实现了多线程,run()没有实现多线程。
创建线程池的方式
1.通过实现 Runnable 接口;实现接口的方式比继承Thread类的方式更灵活,也能减少程序之间的耦合度,面向接口编程也是设计模式6大原则的核心。
2.通过继承 Thread 类本身;
3.通过 Callable 和 Future 创建线程。
1 继承Thread类:
步骤:
①、定义类继承Thread
②、复写Thread类中的run方法;目的:将自定义代码存储在run方法,让线程运行
③、调用线程的start方法:该方法有两步:启动线程,调用run方法。
class InitialThread extends Thread {
private static final Logger LOGGER = LoggerFactory.getLogger(InitialThread.class);
private ZkScheduleManager sm;
public InitialThread(ZkScheduleManager sm) {
this.sm = sm;
}
private boolean isStop = false;
public void stopThread() {
this.isStop = true;
}
@Override
public void run() {
if (isStop) {
return;
}
sm.initLock.lock();
try {
sm.initialData();
} catch (Throwable e) {
LOGGER.error(e.getMessage(), e);
} finally {
sm.initLock.unlock();
}
}
}
class DestroyThread extends Thread {
private static final Logger LOGGER = LoggerFactory.getLogger(DestroyThread.class);
private ZkScheduleManager sm;
private boolean isStop = false;
public void stopThread() {
this.isStop = true;
}
public DestroyThread(ZkScheduleManager sm) {
this.sm = sm;
}
@Override
public void run() {
if (isStop) {
return;
}
LOGGER.info("stop server start... serverId:{}", sm.currentScheduleServer.getUuid());
sm.registerLock.lock();
ZkConfig config = ConfUtils.loadConfig();
try {
config.setStopSchedule(true);
config.setStart(false);
sm.getScheduleDataManager().removeServer(sm.currentScheduleServer);
sm.hearBeatTimer.cancel(); // 如果server从zk上移除成功,停止心跳
sm.hearBeatTimer = null;
LOGGER.info("stop server success!!! serverId:{}", sm.currentScheduleServer.getUuid());
} catch (Throwable t) {
config.setStopSchedule(false);
config.setStart(true);
throw new RuntimeException(t.getMessage(), t);
} finally {
sm.registerLock.unlock();
}
}
}
2 实现Runnable接口: 接口应该由那些打算通过某一线程执行其实例的类来实现。类必须定义一个称为run 的无参方法。
步骤:
①、定义类实现Runnable接口
②、覆盖Runnable接口中的run方法,将线程要运行的代码放在该run方法中。
③、通过Thread类建立线程对象。
④、将Runnable接口的子类对象作为实际参数传递给Thread类的构造函数。
自定义的run方法所属的对象是Runnable接口的子类对象。所以要让线程执行指定对象的run方法就要先明确run方法所属对象
⑤、调用Thread类的start方法开启线程并调用Runnable接口子类的run方法。
@ApiOperation("结束打电话")
@PostMapping("endCall")
public ManageResponse endCall(@NotNull @Valid @RequestBody PostLoanCallRequest request){
//todo: 强校验单号
PostLoanOrderCallVo callVo = new PostLoanOrderCallVo();
BeanUtils.copyProperties(request,callVo);
//塞管理后台信息
callVo.setDialUser(getUser().getName());
callVo.setDialUserId(getUser().getId());
//第三方不支持同步查询, 需要延时异步查询
ScheduledExecutorService executor = Executors.newScheduledThreadPool(3);
executor.schedule(new Runnable(){
@Override
public void run(){
postLoanService.endCall(callVo);
}
},10,TimeUnit.SECONDS);
return new ManageResponse(ManageResponseCode.SUCCESS);
}
3 通过Callable和Future创建线程:
实现步骤:
①、创建Callable接口的实现类,并实现call()方法,改方法将作为线程执行体,且具有返回值。
②、创建Callable实现类的实例,使用FutrueTask类进行包装Callable对象,FutureTask对象封装了Callable对象的call()方法的返回值
③、使用FutureTask对象作为Thread对象启动新线程。
④、调用FutureTask对象的get()方法获取子线程执行结束后的返回值。
//通讯录
public List<ContactRecord> saveContactRecord(final String userId){
List<ContactRecord> contactRecords=dubboCacheManager.get(DubboCacheRegion.CACHE_CONTACT_RECORD,
userId,CreditSystemConstant.PHONE_INFO_CACHE_TIME,new Callable<Object>(){
@Override
public Object call() throws Exception {
logger.info("====缓存没有通讯录的数据,需要拿数据并放入缓存中开始=====");
List<ContactRecord> contactRecords=contactRecordRepository.findByUserId(userId);
if(contactRecords==null){
contactRecords=new ArrayList<ContactRecord>();
}
CreditUtilService.dealContactRecord(contactRecords);
logger.info("====缓存没有通讯录的数据,需要拿数据并放入缓存中完成====="+contactRecords.size());
return contactRecords;
}
});
return contactRecords;
}
4 继承Thread类和实现Runnable接口、实现Callable接口的区别。
继承Thread:线程代码存放在Thread子类run方法中。
优势:编写简单,可直接用this.getname()获取当前线程,不必使用Thread.currentThread()方法。
劣势:已经继承了Thread类,无法再继承其他类。
实现Runnable:线程代码存放在接口的子类的run方法中。
优势:避免了单继承的局限性、多个线程可以共享一个target对象,非常适合多线程处理同一份资源的情形。
实现接口的方式比继承类的方式更灵活,也能减少程序之间的耦合度,面向接口编程也是设计模式6大原则的核心。
劣势:比较复杂、访问线程必须使用Thread.currentThread()方法、无返回值。
实现Callable:
优势:有返回值、避免了单继承的局限性、多个线程可以共享一个target对象,非常适合多线程处理同一份资源的情形。
劣势:比较复杂、访问线程必须使用Thread.currentThread()方法
建议使用实现接口的方式创建多线程。
什么是线程安全
如果你的代码在多线程下执行和在单线程下执行永远都能获得一样的结果,那么你的代码就是线程安全的。
线程安全的几个级别:
(1)不可变
像String、Integer、Long这些,都是final类型的类,任何一个线程都改变不了它们的值,要改变除非新创建一个,因此这些不可变对象不需要任何同步手段就可以直接在多线程环境下使用
(2)绝对线程安全
不管运行时环境如何,调用者都不需要额外的同步措施。要做到这一点通常需要付出许多额外的代价,Java中标注自己是线程安全的类,实际上绝大多数都不是线程安全的,不过绝对线程安全的类,Java中也有,比方说CopyOnWriteArrayList、CopyOnWriteArraySet
(3)相对线程安全
相对线程安全也就是我们通常意义上所说的线程安全,像Vector这种,add、remove方法都是原子操作,不会被打断,但也仅限于此,如果有个线程在遍历某个Vector、有个线程同时在add这个Vector,99%的情况下都会出现ConcurrentModificationException,也就是fail-fast机制。
(4)非线程安全
ArrayList、LinkedList、HashMap等都是非线程安全的类
Demo实战1:
两个对象:Actor/Actress 两个对象,执行相同的逻辑。中途休眠。打印结果可以看到,在cpu处理中,只能处理一个线程,当一个线程休眠之后,另一个线程会开始执行。(CPU来回切换线程)
Demo实战2:
打印结果表明一开始是双方交替执行。到后面,就不是交替的执行。原因是
线程状态管理
Thread.yield();
让出来处理器时间,下次该那个线程执行还不一定呢
可以让正在执行的线程暂停,但是不会进入阻塞状态,而是直接进入就绪状态
Sleep()
产生原因:线程执行的太快,或需要强制执行到下一个线程
方法的作用:线程休眠一定的时间后,继续执行
sleep和yield的区别:
①、sleep方法声明抛出InterruptedException,调用该方法需要捕获该异常。yield没有声明异常,也无需捕获。
②、sleep方法暂停当前线程后,会进入阻塞状态,只有当睡眠时间到了,才会转入就绪状态。而yield方法调用后 ,是直接进入就绪状态。
Join()
方法的作用:join方法使得其他线程必须等待此join方法执行完毕,才能开始执行。(当B线程执行到了A线程的.join()方法时,B线程就会等待,等A线程都执行完毕,B线程才会执行)
此方法在需要结束程序的地方非常有用,join可以用来临时加入线程执行。
如何正确的停止Java中的线程?
1.not stop() ,不允许使用stop(),会导致线程强行停止,程序突然中止。
2.正确停止线程:使用退出标志;这样的结束方式可以让一个线程完成的执行要做的工作。也使的有时间去做代码清理工作。
3.错误的停止线程的interrupt()方法.
interrupt()方法不会中断一个正在运行的线程。
实际上,在线程受到阻塞时抛出一个中断信号,调用interrupt()方法会使该线程退出阻塞状态。
更确切的说,如果线程被Object.wait, Thread.join和Thread.sleep三种方法之一阻塞,它将接收到一个中断异常(InterruptedException),从而提早地终结被阻塞状态,然后该线程还是继续运行的。
线程其他的重要概念
线程的优先级
每一个 Java 线程都有一个优先级,这样有助于操作系统确定线程的调度顺序。
Java 线程的优先级是一个整数,其取值范围是 1 (Thread.MIN_PRIORITY ) - 10 (Thread.MAX_PRIORITY )。
默认情况下,每一个线程都会分配一个优先级 NORM_PRIORITY(5)。
具有较高优先级的线程对程序更重要,并且应该在低优先级的线程之前分配处理器资源。但是,线程优先级不能保证线程执行的顺序,而且非常依赖于平台。
并行和串行:
串行:相对于单条线程执行多个任务来讲。比如下载多个视频,按照顺序一个一个下载完继续下载下一个。在时间上不可能发生重叠。
并行:在同一时刻发生的,并行在时间上是重叠的。下载多个视频,开启多条线程,多个文件同时进行下载。
线程的交互方式(互斥和同步):
(1)互斥:系统资源是有限的,只有当一个线程使用结束后,下一个线程才能开始使用,如果当前有线程占用了有限的资源,则该等待(一个班级中的学习资料是有限的,同学需要竞争去抢占它。如果一个同学正在使用,其他同学需要等待)。
(2)同步:通过相互协作完成某些任务(一个班级同学之间需要相互协作共同完成任务,比如排演节目)。
为什么要进行线程同步?
java允许多线程并发控制,当多个线程同时操作一个可共享资源变量时(如对其进行增删改查操作),会导致数据不准确,而且相互之间产生冲突。所以加入同步锁以避免该线程在没有完成操作前被其他线程调用,从而保证该变量的唯一性和准确性。
好处:解决了多线程的安全问题。
弊端:多个线程需要判断,消耗资源,降低效率。
同步方式
1 同步函数方式:用synchronize关键字修饰的方法。因为每个java对象都有一个内置锁,当用synchronize关键字修饰方法时内置锁会保护整个方法,而在调用该方法之前,要先获得内置锁,否则就会处于阻塞状态。
2 同步代码块:就是拥有synchronize关键字修饰的语句块,被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。
如何找问题?多线程发生问题,如何排查?
1、明确哪些代码是多线程运行代码。
2、明确共享数据。
3、明确多线程运行代码中哪些语句是操作共享数据的。
线程的交互之争用条件
场景:女神周末只能和一个人约会。同时多个追求者来约女生时。女神需要准讯一个人的原则。如果,女生同时约了多个人,那么会导致争用条件的产品。
两个线程同时访问一个共享资源,但CPU只能允许同一时间一个线程执行。两个线程是通过分时和抢占来完成。
1、当多个线程同时共享访问同一数据(内存区域)时,每个线程都尝试操作该数据,从而导致数据被破坏(corrupted),这种现象称为争用条件。
2、原因是,每个线程在操作数据时,会先将数据初值读【取到自己获得的内存中】,然后在内存中进行运算后,重新赋值到数据。
3、争用条件:线程1在还【未重新将值赋回去时】,线程1阻塞,线程2开始访问该数据,然后进行了修改,之后被阻塞的线程1再获得资源,而将之前计算的值覆盖掉线程2所修改的值,就出现了数据丢失情况
线程的交互之之互斥和同步
一.互斥: 同一时间,只能有一个线程访问数据或临界区
二.同步: 一种通信机制,一个线程操作完成后,以某种方式通知其他线程
三.实现方法
1、【互斥】构建锁对象(Object objLock),通过synchronized(lockObj){ 互斥的代码块 }
2、加锁操作会开销系统资源,降低效率
3、在某线程的条件不满足任务时,使用lockObj.wait()对线程进行阻挡,防止其继续竞争CPU资源,滞留在wait set中,等待唤醒,【唤醒后继续完成业务】
4、【同步】在某一代码正确执行完业务后,通过lockObj.notifyAll()唤醒所有在lockObj对象等待的线程
1:线程的互斥是指,在同一时间关键数据只能有一个线程访问
2:线程互斥的实现有synchronized关键字来实现,类似于给对应的代码加锁,只有获得锁的线程才能运行此段代码
3:线程的同步是指,线程间的一种通信控制,一个线程完成了某事后通知另一个线程可以进行下面的事情了。(有人发出消息,有人响应消息,两个线程的合作)
Wait Set:滞留区
一个线程拿到锁资源,进行访问共享资源。但是,不满足条件时,调用wait()方法,此线程进入滞留在WaitSet中,等待条件的满足。释放锁资源,是的其他线程可以获得锁,进行资源处理。当多条线程在Wait Set中等待条件的满足,进行处理时。当当前运行线程执行完某些操作,需要通知等待的线程时,调用notify()方法。唤醒等待区中的一条线程进入当前线程处理。调用notifyall()方法,唤醒所有的等待的线程。
多线程之Happens-Before原则
当一个多线程共享变量被某个线程修改后,如何让这个修改被需要读取这个变量的线程感知到?
https://segmentfault.com/a/1190000011458941
JMM定义了Happens-Before原则。
对于两个操作A和B,这两个操作可以在不同的线程中执行。如果A Happens-Before B,那么可以保证,当A操作执行完后,A操作的执行结果对B操作是可见的。
Happens-Before的规则包括:
1.程序顺序规则
2.锁定规则
3.volatile变量规则
4.线程启动规则
5线程结束规则
6.中断规则
7.终结器规则
8.传递性规则
一.程序顺序规则
在一个线程内部,按照程序代码的书写顺序,书写在前面的代码操作Happens-Before书写在后面的代码操作。这时因为Java语言规范要求JVM在单个线程内部要维护类似严格串行的语义,如果多个操作之间有先后依赖关系,则不允许对这些操作进行重排序。
二.锁定规则
上面这段代码,setValue和getValue两个方法共享同一个监视器锁。假设setValue方法在线程A中执行,getValue方法在线程B中执行。setValue方法会先对value变量赋值,然后释放锁。getValue方法会先获取到同一个锁后,再读取value的值。所以根据锁定原则,线程A中对value变量的修改,可以被线程B感知到。
如果这个两个方法上没有synchronized声明,则在线程A中执行setValue方法对value赋值后,线程B中getValue方法返回的value值并不能保证是最新值。
本条锁定规则对显示锁(ReentrantLock)和内置锁(synchronized)在加锁和解锁等操作上有着相同的内存语义。
对于锁定原则,可以这样理解:同一时刻只能有一个线程执行锁中的操作,所以锁中的操作被重排序外界是不关心的,只要最终结果能被外界感知到就好。除了重排序,剩下影响变量可见性的就是CPU缓存了。在锁被释放时,A线程会把释放锁之前所有的操作结果同步到主内存中,而在获取锁时,B线程会使自己CPU的缓存失效,重新从主内存中读取变量的值。这样,A线程中的操作结果就会被B线程感知到了。
三.volatile变量规则
volatile变量的写入和读取操作流程:volatile变量的操作会禁止与其它普通变量的操作进行重排序。
例如上面代码中会禁止initialized = true与它上面的两行代码进行重排序(但是它上面的代码之间是可以重排序的),否则会导致程序结果错误。volatile变量的写操作就像是一条基准线,到达这条线之后,不管之前的代码有没有重排序,反正到达这条线之后,前面的操作都已完成并生成好结果。
然后,在volatile变量写操作发生后,A线程会把volatile变量本身和书写在它之前的那些操作的执行结果一起同步到主内存中。
最后,当B线程读取volatile变量时,B线程会使自己的CPU缓存失效,重新从主内存读取所需变量的值,这样无论是volatile本身,还是书写在volatile变量写操作之前的那些操作结果,都能让B线程感知到,也就是上面程序中的initialized和configOptions变量的最新值都可以让线程B感知到。
原子变量与volatile变量在读操作和写操作上有着相同的语义。
四.线程启动规则
调用start方法时,会将start方法之前所有操作的结果同步到主内存中,新线程创建好后,需要从主内存获取数据。这样在start方法调用之前的所有操作结果对于新创建的线程都是可见的。
五.线程终止规则
线程中的任何操作都Happens-Before其它线程检测到该线程已经结束。这个说法有些抽象,下面举例子对其进行说明。
假设两个线程s、t。在线程s中调用t.join()方法。则线程s会被挂起,等待t线程运行结束才能恢复执行。当t.join()成功返回时,s线程就知道t线程已经结束了。所以根据本条原则,在t线程中对共享变量的修改,对s线程都是可见的。类似的还有Thread.isAlive方法也可以检测到一个线程是否结束。
可以猜测,当一个线程结束时,会把自己所有操作的结果都同步到主内存。而任何其它线程当发现这个线程已经执行结束了,就会从主内存中重新刷新最新的变量值。所以结束的线程A对共享变量的修改,对于其它检测了A线程是否结束的线程是可见的。
六.中断规则
一个线程在另一个线程上调用interrupt,Happens-Before被中断线程检测到interrupt被调用。
假设两个线程A和B,A先做了一些操作operationA,然后调用B线程的interrupt方法。当B线程感知到自己的中断标识被设置时(通过抛出InterruptedException,或调用interrupted和isInterrupted),operationA中的操作结果对B都是可见的。
七.终结器规则
一个对象的构造函数执行结束Happens-Before它的finalize()方法的开始。
“结束”和“开始”表明在时间上,一个对象的构造函数必须在它的finalize()方法调用时执行完。
根据这条原则,可以确保在对象的finalize方法执行时,该对象的所有field字段值都是可见的。
八.传递性规则
如果操作A Happens-Before B,B Happens-Before C,那么可以得出操作A Happens-Before C。
规则的意义:
导致多线程间可见性问题的两个“罪魁祸首”是CPU缓存和重排序。
如果要保证多个线程间共享的变量对每个线程都及时可见,一种极端的做法就是禁止使用所有的重排序和CPU缓存。即关闭所有的编译器、操作系统和处理器的优化,所有指令顺序全部按照程序代码书写的顺序执行。去掉CPU高速缓存,让CPU的每次读写操作都直接与主存交互。
当然,上面的这种极端方案是绝对不可取的,因为这会极大影响处理器的计算性能,并且对于那些非多线程共享的变量是不公平的。
重排序和CPU高速缓存有利于计算机性能的提高,但却对多CPU处理的一致性带来了影响。为了解决这个矛盾,我们可以采取一种折中的办法。我们用分割线把整个程序划分成几个程序块,在每个程序块内部的指令是可以重排序的,但是分割线上的指令与程序块的其它指令之间是不可以重排序的。在一个程序块内部,CPU不用每次都与主内存进行交互,只需要在CPU缓存中执行读写操作即可,但是当程序执行到分割线处,CPU必须将执行结果同步到主内存或从主内存读取最新的变量值。那么,Happens-Before规则就是定义了这些程序块的分割线。
下图展示了一个使用锁定原则作为分割线的例子:
如图所示,这里的unlock M和lock M就是划分程序的分割线。在这里,红色区域和绿色区域的代码内部是可以进行重排序的,但是unlock和lock操作是不能与它们进行重排序的。即第一个图中的红色部分必须要在unlock M指令之前全部执行完,第二个图中的绿色部分必须全部在lock M指令之后执行。并且在第一个图中的unlock M指令处,红色部分的执行结果要全部刷新到主存中,在第二个图中的lock M指令处,绿色部分用到的变量都要从主存中重新读取。
在程序中加入分割线将其划分成多个程序块,虽然在程序块内部代码仍然可能被重排序,但是保证了程序代码在宏观上是有序的。并且可以确保在分割线处,CPU一定会和主内存进行交互。Happens-Before原则就是定义了程序中什么样的代码可以作为分隔线。并且无论是哪条Happens-Before原则,它们所产生分割线的作用都是相同的。