JAVA多线程总结

Java多线程实现方式主要有四种:

继承Thread类
实现Runnable接口、
实现Callable接口通过FutureTask包装器来创建Thread线程、
使用ExecutorService、Callable、Future实现有返回结果的多线程。

1、继承Thread类创建线程
Thread类本质上是实现了Runnable接口的一个实例,代表一个线程的实例。启动线程的唯一方法就是通过Thread类的start()实例方法。start()方法是一个native方法,它将启动一个新线程,并执行run()方法。这种方式实现多线程很简单,通过自己的类直接extend Thread,并复写run()方法,就可以启动新线程并执行自己定义的run()方法。

2、实现Runnable接口创建线程
如果自己的类已经extends另一个类,就无法直接extends Thread,此时,可以实现一个Runnable接口

上面的两种方式都有这两个问题:
1. 无法获取子线程的返回值
2.run方法不可以抛出异常
为了解决这两个问题,我们就需要用到Callable这个接口了。说到接口,上面的Runnable接口实现类实例是作为Thread类的构造函数的参数传入的,之后通过Thread的start执行run方法中的内容。但是Callable并不是Runnable的子接口,是个全新的接口,它的实例不能直接传入给Thread构造,所以需要另一个接口来转换一下。

3、实现Callable接口通过FutureTask包装器来创建Thread线程
a:创建Callable接口的实现类 ,并实现Call方法
  b:创建Callable实现类的实现,使用FutureTask类包装Callable对象,该FutureTask对象封装了Callable对象的Call方法的返回值
  c:使用FutureTask对象作为Thread对象的target创建并启动线程
  d:调用FutureTask对象的get()来获取子线程执行结束的返回值

4、使用ExecutorService、Callable、Future实现有返回结果的线程
ExecutorService、Callable、Future三个接口实际上都是属于Executor框架。返回结果的线程是在JDK1.5中引入的新特征,有了这种特征就不需要再为了得到返回值而大费周折了。而且自己实现了也可能漏洞百出。
可返回值的任务必须实现Callable接口。类似的,无返回值的任务必须实现Runnable接口。

5.UML图描述线程的生命周期
在这里插入图片描述
5.1线程池减少了线程的创建、消亡、开销
在这里插入图片描述1. 提交任务后,首先会判断核心线程数是否已满,如果在线程池中的线程数量没有达到核心线程数,这时会创建核心线程来执行任务;否则,进入下一步操作。
2. 接着判断线程池任务队列是否已满。如果没满,则将任务添加到任务队列中;否则,进入下一步操作。
3. 接着判断线程池中的线程数是否达到最大线程数(执行这步的前提是任务队列已满了)。如果未达到,则创建非核心线程执行任务;否则,就执行饱和策略,默认会抛出RejectedExecutionException异常。

**线程池的优势:**1. 降低资源消耗。通过重复利用已经创建的线程降低线程的创建与销毁造成的消耗。
2. 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
3. 提高线程的可管理性。线程是稀缺资源,如果无限制第创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。

java提供了常见四种线程池,分别可以通过Executors类获取:
FixedThreadPool、CachedThreadPool、ScheduledThreadPool、SingleThreadExecutor。

6.线程的调度与控制

CPU在某个时刻只能执行一条指令,线程只有得到CPU时间片,才能执行命令
JVM负责线程的调度,取得CPU的时间片。优先级高的线程获取的CPU时间片相对多一些
Thread类的sleep()方法对当前线程操作,是静态方法。
sleep()的参数指定以毫秒为单位的线程休眠时间。除非因为中断而提早恢复执行,否则线程不会在这段时间之前恢复执行

线程的调度
计算机通常只有一个CPU,在任意时刻只能执行一条机器指令,每个线程只有获得CPU的使用权才能执行指令。所谓多线程的并发运行,其实是从宏观上看,各个线程轮流获得CPU的使用权才能执行指令,分别执行各自的任务。在可运行池中,会有多个处于就绪态的线程在等待CPU,Java虚拟机的一项任务就是负责线程的调度。线程的调度是指按照特定的机制为多个线程分配CPU的使用权。有两种调度模型:分时调度模型和抢占式调度模型
分时调度模型是指让所有线程轮流获得CPU的使用权,并且平均分配每个线程占用CPU的时间片。
Java虚拟机采用抢占式调度模型,是指优先让可运行池中处于就绪态的线程中优先级高的占用CPU,如果可运行池中线程的优先级相同,那么就随机选择一个线程,使其占用CPU,处于运行状态的线程会一直执行,直至它不得不放弃CPU,一个线程会因为以下原因放弃CPU:
(1)Java虚拟机让当前线程暂时放弃CPU,转到就绪态,使其他线程获得运行机会
(2)当前线程因为某些原因而处于阻塞状态
(3)线程运行结束
值得注意的是,线程的调度不是跨平台的,它不仅取决于Java虚拟机,还依赖于操作系统。在某些操作系统中,即使运行中的线程没有遇到阻塞,也会在运行一段时间后放弃CPU,给其他线程运行的机会。

java线程的调度不是分时的,同时启动多个线程后,不能保证各个线程轮流获得均等的时间片

6.1.调整各个线程的优先级
所有处于就绪状态的线程根据优先级存放在可运行池中,优先级低的线程获得较少的运行机会,优先级高的获得较多的运行机会。Thread类的setPriority(int)和getPriority()方法用于设置和读取优先级。优先级用整数表示,取值范围时1-10,Thread类有以下三个静态常量。
MAX_PRIORITY:取值是10,表示最高优先级
MIN_PRIORITY:取值是1,表示最低优先级
DEFAULT_PRIORITY:取值是5,表示默认优先级
每个线程都有其优先级。主线程的默认优先级是Thread.DEFAULT_PRIORITY。如果线程A创建了线程B,那么线程B就和线程A具有同样的优先级
值得注意的是,尽管Java提供了10个优先级,但它与多数操作系统都不能很好的映射。比如windows2000有7个优先级,并且不是固定的,而sun公司的Solaris操作系统有2^31个优先级。如果希望程序能移植到各个操作系统中,应该确保在设置线程的优先级时,只使用MIN_PRIORITY,DEAULT_PRIORITY,MAX_PRIORITY这三个优先级。这样才能保证在不同的操作系统中,对同样优先级的线程采用同样的调度方式
6.2.线程阻塞与睡眠:Thread.sleep()方法
Thread.sleep(500);会让主线程睡眠500毫秒。Thread.sleep(ms),是一个静态方法,阻塞当前线程,腾出CPU,让给其他线程
当一个线程在运行中sleep()方法,它就会放弃cpu,转到阻塞状态。Thread类的sleep(longmillis)方法是静态的,millis参数设置睡眠的时间,单位是毫秒。
假如线程1线程进入睡眠,线程2获得cpu,当线程1结束睡眠后,后先进入就绪状态,假如线程2正在运行,线程1并不一定会立即执行,而是在可运行池中等待获取CPU
6.2.1 、sleep()方法会给其他线程运行的机会,而不考虑其他线程的优先级,因此会给较低优先级线程一个运行的机会;yield()方法只会给相同优先级或者更高优先级的线程一个运行的机会
6.2.2 、当线程执行sleep(longmillis)方法后,将转到阻塞状态,参数millis制定睡眠时间;当线程执行yield()方法后,将转到就绪状态
6.2.3 、sleep()方法声明抛出InterruptedException异常,而yield()方法没有声明抛出任何异常
6.2.4 、sleep()方法比yield()方法具有更好的可移植性。不能依靠yield()方法来提高程序的并发性能。对于大多数程序员来说,yield()方法的唯一用途是在测试期间人为地提高程序的并发性能,以帮助发现一些隐藏的错误。
6.3.线程让步:Thread.yield()方法
当线程在运行中执行了Thread类的yield()静态方法时,如果此时具有相同优先级的其他线程处于就绪状态,那么yield()方法将把当前运行的线程放到可运行池中并使另一个线程运行。如果没有相同优先级的可运行进程,则yield()方法什么也不做
sleep()方法和yield()方法都是Thread类的静态方法,都会使当前处于运行状态的线程放弃CPU,把运行机会让给别的线程。两者的区别在于
返回当前线程对象的引用:Thread类的currentThread()静态方法返回当前线程对象的引用
6.3.1.后台线程
后台线程是指为其他线提供服务的线程,也称为守护线程。Java虚拟机的垃圾回收线程时典型的后台线程,它负责回收其他线程不再使用的内存
后台线程的特点是:后台线程与前台线程相伴相随,只有所有的前台线程都结束生命周期。调用Thread类的setDaemaon(true)方法,就能把一个线程设置为一个后台线程。Thread类的isDaemon()方法用来判断一个线程是不是后台线程
在使用后台线程时,有以下注意点:
(1)Java虚拟机所能保证的是,当所有前台进程结束时,假如后台进程还在运行,Java虚拟机就会终止后台线程。此外,后台线程是否一定在前台线程的后面结束生命周期,还取决于程序的实现。
(2)只有在线程启动前(即调用start()方法以前),才能把它设置为后台线程。如果线程启动后,再调用这个线程的setDaemon()方法,就会导致IllegalThreadStateException异常
(3)由前台线程创建的线程在默认情况下仍然是前台线程,由后台线程创建的线程默认情况下也是后台线程
定时器:Timer
Timer类本身不是线程类,但是在它的实现中,利用线程来执行定时任务,timer.schedule(task,100,500)表示100毫秒后执行task任务,以后每隔500毫秒执行一次
6.4.等待其他线程结束:join()
当前运行的线程可以调用另一个线程的join()方法,当前运行的线程将转到阻塞状态,直至另一个线程运行结束,它才会恢复运行(阻塞恢复到就绪)

7.线程终止(interrupt)
本线程中断自己是被允许的;其它线程调用本线程的interrupt()方法时,会通过checkAccess()检查权限。这有可能抛出SecurityException异常。
如果本线程是处于阻塞状态:
调用线程的wait(), wait(long)或wait(long, int)会让它进入等待(阻塞)状态,
或者调用线程的join(), join(long), join(long, int), sleep(long), sleep(long, int)也会让它进入阻塞状态。
若线程在阻塞状态时,调用了它的interrupt()方法,那么它的“中断状态”会被清除并且会收到一个InterruptedException异常

线程终止的方式
Thread中的stop()和suspend()方法,由于固有的不安全性,已经建议不再使用!
下面,我先分别讨论线程在“阻塞状态”和“运行状态”的终止方式,然后再总结出一个通用的方式。
1、终止处于“阻塞状态”的线程
通常,我们通过“中断”方式终止处于“阻塞状态”的线程。
当线程由于被调用了sleep(), wait(), join()等方法而进入阻塞状态;若此时调用线程的interrupt()将线程的中断标记设为true。由于处于阻塞状态,中断标记会被清除,同时产生一个InterruptedException异常。将InterruptedException放在适当的位置就能终止线程,
2、终止处于“运行状态”的线程
通常,我们通过“标记”方式终止处于“运行状态”的线程。其中,包括“中断标记”和“额外添加标记”。
(1)通过“中断标记”终止线程
在这里插入图片描述
(2)通过“额外添加标记”。在这里插入图片描述

8.线程的同步(加锁)主要通过synchronized的关键字和Lock(锁)来实现的

异步编程模型:t1线程执行t1的,t2线程执行t2的,两线程之间谁也不等谁
同步编程模型:t1线程和t2线程执行,t1必须等t2线程执行结束后才能执行

多个线程操作同一资源
并发:同一对象被多个线程同时操作:上万人同时抢100张票;两个银行同时取钱;

处理多线程问题时 , 多个线程访问同一个对象 , 并且某些线程还想修改这个对象 .这时候我们就需要线程同步 . 线程同步其实就是一种等待机制 , 多个需要同时访问此对象的线程进入这个对象的等待池 形成队列, 等待前面线程使用完毕 , 下一个线程再使用

synchronized的关键字

由于我们可以通过 private 关键字来保证数据对象只能被方法访问 , 所以我们只需要针对方法提出一套机制 , 这套机制就是 synchronized 关键字 , 它包括两种用法 :
synchronized 方法 和synchronized 块 :
一、同步方法: public synchronized void method(int args) {}
synchronized方法控制对 “对象” 的访问 , 每个对象对应一把锁 , 每个synchronized方法都必须获得调用该方法的对象的锁才能执行 , 否则线程会阻塞 ;
方法一旦执行 , 就独占该锁 , 直到该方法返回才释放锁 , 后面被阻塞的线程才能获得这个锁 , 继续执行;
缺陷 : 若将一个大的方法申明为synchronized 将会影响效率

同步方法的弊端
方法里面需要修改的内容才需要锁,
锁的太多 , 浪费资源

二、同步块
同步块 : synchronized (Obj ) { }
Obj 称之为 同步监视器
Obj 可以是任何对象 , 但是推荐使用共享资源作为同步监视器
同步方法中无需指定同步监视器 , 因为同步方法的同步监视器就是this , 就是这个对象本身 , 或者是 class [ 反射中讲解 ]

同步监视器的执行过程
第一个线程访问 , 锁定同步监视器 , 执行其中代码 .
第二个线程访问 , 发现同步监视器被锁定 , 无法访问 .
第一个线程访问完毕 , 解锁同步监视器 .
第二个线程访问, 发现同步监视器没有锁 , 然后锁定并访问

Lock(锁)

从JDK 5.0开始,Java提供了更强大的线程同步机制——通过显式定义同步锁对象来实现同步。同步锁使用Lock对象充当
java.util.concurrent.locks.Lock接口是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象
ReentrantLock 类实现了 Lock ,它拥有与 synchronized 相同的并发性和内存语义,在实现线程安全的控制中,比较常用的是ReentrantLock,可以显式加锁、释放锁
在这里插入图片描述
案例:
Lock锁
在这里插入图片描述
活锁
是一系列线程在轮询地等待某个不可能为真的条件为真。活锁的时候进程是不会blocked,这会导致耗尽CPU资源
如线程a从队列中取出任务1来执行,如果任务执行失败,那么将任务重新加入队列,继续执行。假设任务总是执行失败,那么线程一直在繁忙却没有任何结果
如线程a请求资源1,如失败请求资源2,线程b用同样的逻辑请求资源1,2,a和b不断的同时失败,同时改变逻辑,再同时失败…
可以通过加随机等待时间,如果检测到冲突,那么就暂停随机的一定时间进行重试解决
处于活锁的实体是在不断的改变状态, 而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。

死锁
死锁分为两种:

1.一个线程获取到锁,没释放的时候再次获取该锁,造成死锁
可以通过可重入锁避免
可重入锁:线程获取一个资源的锁,可以再次获取该资源的锁,建立一个标志数,每获取一次,标志数加一,每释放一次,标志数减一,标志数为0,解锁

2.线程a获取资源1的锁同时等待资源2,线程b获取资源2的锁同时等待资源1,这样线程a和b进入死锁
可以通过修改资源访问顺序的方式避免死锁,把线程a和b都修改为先获取资源1再获取资源2

死锁避免方法:
产生死锁的四个必要条件:

1.互斥条件:一个资源每次只能被一个进程使用。
2.请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放
3.不剥夺条件 : 进程已获得的资源,在末使用完之前,不能强行剥夺。
4.循环等待条件 : 若干进程之间形成一种头尾相接的循环等待资源关系。
上面列出了死锁的四个必要条件,我们只要想办法破其中的任意一个或多个条件
就可以避免死锁发生

案例:
死锁问题
两个线程抱着自己的锁 , 然后想要对方的锁 .
于是产生一个问题 —> 死锁
在这里插入图片描述结果输出:
程序死了
synchronized 与 Lock 的对比

Lock是显式锁(手动开启和关闭锁,别忘记关闭锁)synchronized是隐式锁,出了作用域自动释放
Lock只有代码块锁,synchronized有代码块锁和方法锁
– 使用Lock锁,JVM将花费较少的时间来调度线程,性能更好。并且具有更好的扩展性(提供更多的子类)
优先使用顺序:
Lock > 同步代码块(已经进入了方法体,分配了相应资源)> 同步方法(在方法体之外)

2 锁的深度化
2.1 悲观锁

悲观锁认为每次查询数据数据都会造成数据的更新或者丢失问题,所以每次查询都会加上排它锁。

在这里插入图片描述
如图所示:当有两个jdbc连接同时操作以上sql时,会产生数据脏读导致总金额为真实的两倍。使用悲观锁则通过在其后加for update后,仅允许一个连接查询数据也就是只要一个连接获得锁后,其他连接则只能等待该锁的释放。
缺点:每次只有一个连接操作,效率极低,适合查询量下的情况

2.2 乐观锁
乐观锁认为每次查询都不会造成数据更新丢失,使用版本字段控制。
在这里插入图片描述
如图所示,当两个线程同时操作时,如果线程1执行到第一个update语句,它将会将version值设置为原来的加1,此时线程2就无法通过version来获取该条已经修改过的记录,从而保证了数据重复读、写。通过第二句的影响行数是否大于0来判断金额的更新问题。优点可并发运行,效率较高。缺点需要加个version字段而且有可能发生查不到的情况

乐观锁与CAS类似,代码不加锁,默认不会出现资源竞争问题
为表中加一个 version 字段;
当读取数据时,连同这个 version 字段一起读出;
数据每更新一次就将此值加一;(此种方式可以避免ABA问题)
当提交更新时,判断数据库表中对应记录的当前版本号是否与之前取出来的版本号一致,如果一致则可以直接更新,如果不一致则表示是过期数据需要重试或者做其它操作

2.3 重入锁
**重入锁,也叫做递归锁,**指的是同一线程 外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。
非重入锁进行以上操作的话就会产生死锁。可结合上面案例查看。
在这里插入图片描述
2.4 读写锁

假设你的程序中涉及到对一些共享资源的读和写操作,且写操作没有读操作那么频繁。在没有写操作的时候,两个线程同时读一个资源没有任何问题,所以应该允许多个线程能在同时读取共享资源。但是如果有一个线程想去写这些共享资源,就不应该再有其它线程对该资源进行读或写(译者注:也就是说:读-读能共存,读-写不能共存,写-写不能共存)。这就需要一个读/写锁来解决这个问题。常用语缓存设计。
在这里插入图片描述
2.5 CAS无锁机制

原子类底层使用CAS无锁机制实现保证线程安全,CAS无锁机制效率比有锁机制高。

(1)与锁相比,使用比较交换(下文简称CAS)会使程序看起来更加复杂一些。但由于其非阻塞性,它对死锁问题天生免疫,并且,线程间的相互影响也远远比基于锁的方式要小。更为重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,因此,它要比基于锁的方式拥有更优越的性能。

(2)无锁的好处:

第一,在高并发的情况下,它比有锁的程序拥有更好的性能;

第二,它天生就是死锁免疫的。

就凭借这两个优势,就值得我们冒险尝试使用无锁的并发。

(3)CAS算法的过程是这样:它包含三个参数CAS(V,E,N): V表示要更新的变量,E表示预期值,N表示新值。仅当V值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS返回当前V的真实值。

(4)CAS操作是抱着乐观的态度进行的,它总是认为自己可以成功完成操作。当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,CAS操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。

(5)简单地说,CAS需要你额外给出一个期望值,也就是你认为这个变量现在应该是什么样子的。如果变量不是你想象的那样,那说明它已经被别人修改过了。你就重新读取,再次尝试修改就好了。

(6)在硬件层面,大部分的现代处理器都已经支持原子化的CAS指令。在JDK 5.0以后,虚拟机便可以使用这个指令来实现并发操作和并发数据结构,并且,这种操作在虚拟机中可以说是无处不在。
在这里插入图片描述
2.6 自旋锁
自旋锁是采用让当前线程不停地的在循环体内执行实现的,当循环的条件被其他线程改变时 才能进入临界区。
在这里插入图片描述
当一个线程 调用这个不可重入的自旋锁去加锁的时候没问题,当再次调用lock()的时候,因为自旋锁的持有引用已经不为空了,该线程对象会误认为是别人的线程持有了自旋锁

使用了CAS原子操作,lock函数将owner设置为当前线程,并且预测原来的值为空。unlock函数将owner设置为null,并且预测值为当前线程。

当有第二个线程调用lock操作时由于owner值不为空,导致循环一直被执行,直至第一个线程调用unlock函数将owner设置为null,第二个线程才能进入临界区。

由于自旋锁只是将当前线程不停地执行循环体,不进行线程状态的改变,所以响应速度更快。但当线程数不停增加时,性能下降明显,因为每个线程都需要执行,占用CPU时间。如果线程竞争不激烈,并且保持锁的时间段。适合使用自旋锁。

2.7 分布式锁

如果想在不同的jvm中保证数据同步,使用分布式锁技术。

有数据库实现、缓存实现、Zookeeper分布式锁
分布式锁
分布式锁研究的是多个进程之间的锁,一般在多线程中,可以采取内存中的对象上的锁标志加锁解锁和标记要锁的数据的地址(指针),但是多线程,一般互相的内存是不互通的,所以要采取使用第三方的工具
一般可以采取
1.乐观锁(版本号)的方式
2.利用 Redis Key唯一性(如下单系统用每个订单的编号做key,下单成功存入Redis,如果该key存在,则说明锁着,其余进程无法操作该订单)

队列和锁
由于同一进程的多个线程共享同一块存储空间 , 在带来方便的同时,也带来了访问冲突问题 , 为了保证数据在方法中被访问时的正确性 , 在访问时加入 锁机制synchronized , 当一个线程获得对象的排它锁 , 独占资源 , 其他线程必须等待 ,使用后释放锁即可 .
存在以下问题 :
一个线程持有锁会导致其他所有需要此锁的线程挂起 ;
在多线程竞争下 , 加锁 , 释放锁会导致比较多的上下文切换 和 调度延时,引起性能问题 ;
如果一个优先级高的线程等待一个优先级低的线程释放锁 会导致优先级倒置 , 引起性能问题 .

为什么要引入线程同步呢?
为了数据的安全。尽管应用程序的使用率降低,但是为了保证数据是安全的,必须加入线程同步机制。(线程同步机制让程序等同于单线程)

什么条件下要使用线程同步?
必须是多线程环境
多线程环境共享同一个数据
共享的数据涉及到修改操作
模拟银行取款场景(synchronized关键字也可直接添加到成员方法上)
原理:T1线程执行到此处遇到synchronized关键字,就会去找this的对象锁,找到则进入同步语句块执行程序,执行完再归还对象锁
在T1线程执行同步语句块的过程中,若T2线程执行到此处也遇到synchronized关键字,但未找到this的对象锁,只能等待T1归还
(synchronized添加到静态方法上,线程执行此方法时会找类锁)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值