目录
1.18.2.1.5 线程的 sleep()方法和 yield()方法的区别
1.18.9.3.1 Java 中能创建 volatile 数组吗?
1.18.9.3.2 volatile 能使得一个非原子操作变成原子操作吗?
前言
本篇内容较长,主要是想要把java初级涉及到的线程所有知识都汇集到此。一是方便大家查阅,二是显的线程知识体系更加系统规范。大家可以根据需要从目录中查找自己所需内容以及衍生内容。
1.18.1基本概念
1.18.1.1程序、进程、线程
程序:完成特定任务、用某种语言编写的一组指令的集合。
进程:是程序的一次执行过程。程序是静态的,进程是动态的,进程作为资源分配的单位, 系统在运行时会为每个进程分配不同的内存区域。
线程:它被包含在进程
之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流
●线程作为调度和执行的单位,每个线程拥有独立的运行栈和程序计数器 ( pc)
●多个线程,共享同一个进程中的结构:方法区、堆。
1.18.1.2并行与并发
并行:若干个程序段同时在系统中运行,宏观、微观上程序都是一起执行的
并发:在同一个时间段内,多个程序执行。时间上重叠(宏观上是同时,微观上仍是顺序执行)。可以理解为一个CPU(采用时间片)同时执行多个任务。
1.18.1.3线程优先级
MAX_PRIORITY:10
MIN _PRIORITY:1
NORM_PRIORITY:5 -->默认优先级
如何获取和设置当前线程的优先级
方法 | 说明 |
getPriority(): | 获取线程的优先级 |
setPriority(int p): | 设置线程的优先级 |
1.18.1.4用户线程和守护线程
java中两类线程:
- User Thread(用户线程/非守护线程)
- Daemon Thread(守护线程)
任何一个守护线程都会守护整个JVM中所有的非守护线程,只要当前JVM中还有任何一个非守护线程没有结束,守护线程就全部工作,当所有的非守护线程全部结束后,守护线程也会随着JVM一同结束。守护线程最典型的应用就是GC(垃圾回收器)。
需要注意的地方:
- thread.setDaemon(true)方法必须在thread.start()之前设置,否则会报IllegalThreadStateException异常,不能把正在运行的常规线程设置为守护线程。
- 在守护线程中产生的新线程也是守护线程。
- 不是所有应用都可以分配守护线程来进行服务,比如读写操作或是计算逻辑等。因为如果非守护线程都结束了,但是读写或计算逻辑没有完成,守护线程也会停止。
- 判断线程是否为守护线程的方法是:isDaemon(),返回true为守护线程,返回false为非守护线程
1.18.1.5线程生命周期
- 新建: 当 一个 Thread 类或其子类的对象被声明并创建时,新生的线程对象处于新建状态。
- 就绪: 处于新建状态的线程被 start() 后,将进入线程队列等待 CPU 时间片,此时它已具备了运行的条件 ,只是没分配到 CPU 资源。
- 运行: 当就绪的线程被调度并获得 CPU 资源时便进入运行状态, run() 方法定义了线程的操作和功能。
- 阻塞: 在某种特殊情况下,被人为挂起或执行输入输出操作时,让出 CPU 并临时中止自己的执行,进入阻塞状态。
- 死亡: 线程完成了它的全部工作或线程被提前强制性地中止或出现异常导致结束。
1.18.2 多线程的实现
三种实现方法:
- 继承 Thread 类;
- 实现 Runnable 接口。
- 实现 Callable 接口
前两种方式都要通过重写 run()方法来定义线程的行为,推荐使用Runnable接口,因为 Java 中的继承是单继承,一个类有一个父类,如果继承了 Thread 类 就无法再继承其他类了,显然使用 Runnable 接口更为灵活。
Callable 接口是Java 5 以后引入的,该接口中的 call 方法可以在线程执行结束时产生一个返回值
1.18.2.1继承Thread类
java.lang.Thread
类代表线程,所有的线程对象都必须是Thread类或其子类的实例。
1.18.2.1.1特点
- 每个线程都是通过某个特定 Thread 对象的 run() 方法来完成操作的,经常把 run() 方法的主体称为线程体。
- 通过该 Thread 对象的 start() 方法 来启动这个线程,而非直接调用 run()。
1.18.2.1.2方法
方法 | 说明 |
start() | 启动当前线程;调用当前线程的run()。 |
run() | 通常需要重写Thread类中的此方法,将创建的线程要执行的操作声明在此方法中 |
currentThread() | 静态方法,返回执行当前代码的线程 |
getName() | 获取当前线程的名字 |
setName() | 置当前线程的名字 |
yield() | 释放当前cpu的执行权 |
join() | 在线程A中调用线程B的join(),此时线程A就进入阻塞状态,直到线程B完全执行完以后,线程A才结束阻塞状态 |
sleep(long millitime) | 让当前线程“睡眠”指定的millitime毫秒。在指定的millitime毫秒时间内,当前线程是阻塞状态。 |
isAlive() | 判断当前线程是否存活 |
1.18.2.1.3继承Thread类
步骤
定义Thread类的子类,并重写该类的run()方法,该run()方法的方法体就代表了线程需要完成的任务,因此把run()方法称为线程执行体。
创建Thread子类的实例,即创建了线程对象
调用线程对象的start()方法来启动该线程
public class MyThread extends Thread {
@Override
public void run() {
重写方法
}
}
---------------------
在main方法中new线程对象,并调用start方法
//创建自定义线程对象
MyThread mt = new MyThread();
//开启新线程
mt.start();
思考一个问题,为什么不new thread对象后直接调用run方法,而是用start?
我们用代码来探讨一下这个问题
首先定义Mythread类
class MyThread extends Thread{
String thname;
public MyThread(String thname){
this.thname=thname;
}
@Override
public void run(){
for(int i=0;i<3;i++){
System.out.println(this.thname+"="+i);
}
}
}
在main方法中创建3个线程直接调用run方法
public class Main{
public static void main(String[]args){
new MyThread("线程a").run();
new MyThread("线程b").run();
new MyThread("线程c").run();
}
}
运行结果
我们发现,并没有出现多线程,还是一个线程,按顺序执行。因此发现,我们可以对象.run(),来运行run方法,但是JVM只会将其当作是main函数下的一个普通函数来执行,并不会新建一个线程运行线程执行体(即run()方法)的内容,还是一个线程。
接着,我们修改代码
调用start方法来启动线程
new MyThread("线程a").start();
new MyThread("线程b").start();
new MyThread("线程c").start();
运行结果
我们发现,出现多线程了。也就是说,这个start是一个多线程的开关。那为什么它调用的run()可以实现多线程?
我们打开源码来研究一下
导致该线程开始执行的原因是,jvm(java虚拟机)调用这个线程的run()方法。
正在执行的线程(此线程通过start()产生)和另一个线程(执行了它的run()),可以使得两个线程同时执行。
启动一个线程超过一次是不合法的,特别的,一旦一个线程已经执行完,就不能再重新启动。
我们再来思考一个问题,为什么java要这样设计线程?
很明显,java最大的优点是什么?是跨平台可移植性。那么不同的操作系统可能有不同的函数调用,而我们java开发者显然又不需要去考虑这些不同的函数调用,我们只需要一股脑开发就行,那么这些根据不同的操作系统来选择底层函数调用的工作就交给了jvm,相应的,这项技术被称为JNI(Java Native Interface)技术。
我们来看源码(如果读者以前了解线程可以忽略这段,只需要记住结论即可。但是,如果之前没有了解过,希望跟随着仔细看完,只需要研究过这一次就行)
public synchronized void start() {
/**
* This method is not invoked for the main method thread or "system"
invoked:援引
main方法或者系统并没有调用这个start方法
* group threads created/set up by the VM. Any new functionality added
组线程是由VM(虚拟机)创建/设置的,一些新的方法被添加
* to this method in the future may have to also be added to the VM.
在将来 这个start方法可能会被加入到vm中
* A zero status value corresponds to state "NEW".
corresponds:一致,符合,类似于
零状态等价于 状态new
*/
if (threadStatus != 0)
throw new IllegalThreadStateException();
当线程状态不等于0时抛出IllegalThreadStateException()异常
/* Notify the group that this thread is about to be started
通知这个线程组将会被启动
* so that it can be added to the group's list of threads
以至于它能够被加入线程组列表中
* and the group's unstarted count can be decremented. */
并且这个组的未开始计数将递减
group.add(this);
boolean started = false;
try {
start0();
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
/* do nothing. If start0 threw a Throwable then
it will be passed up the call stack */
}
}
}
private native void start0();
/**
* If this thread was constructed using a separate
如果这个线程是使用一个单独的Runnable运行对象构造的,
* <code>Runnable</code> run object, then that
* <code>Runnable</code> object's <code>run</code> method is called;
那么调用该Runnable对象的run方法;
* otherwise, this method does nothing and returns.
否则,此方法不执行任何操作并返回。
简单来说就是自己定义一个类继承Thread类并覆写run()方法,否则当你调用run方法时什么都不会做。
* <p>
* Subclasses of <code>Thread</code> should override this method.
*
* @see #start()
* @see #stop()
* @see #Thread(ThreadGroup, Runnable, String)
*/
@Override
public void run() {
if (target != null) {
target.run();
}
}
/**
* This method is called by the system to give a Thread
此方法由系统调用以提供线程!!!!
* a chance to clean up before it actually exits.
在它退出前进行清除
*/
注意最后强调run方法由系统调用以提供线程!!!!
所以当我们在Java程序中调用start()方法时,然后start()方法会去调用start0(),而start0()只有定义没有实现,那么实现是由谁来做的呢?是JVM来做,JVM会根据用户系统类型实现相应的start0()方法,以此实现多线程。而直接调用run()方法并不能达到此效果。
1.18.2.1.4 run方法与start方法的区别
- 调用方不同:start方法由开发人员调用开启线程,run方法由系统分配资源后自动调用
- 生命周期不同:start方法会将线程由初始态转换为就绪态,run方法会将线程由就绪态转换为运行态
- 执行方式不同:start方法由开发人员手动调用才能执行,run方法时系统资源调度后自动执行,且run方法也可以直接执行,但是直接执行就是在当前线程直接执行代码,不会创建新的线程
1.18.2.1.5 线程的 sleep()方法和 yield()方法的区别
- sleep()方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;yield()方法只会给相同优先级或更高优先级的线程以运行的机会;
- 线程执行 sleep()方法后转入阻塞(blocked)状态,而执行 yield()方法后转入就绪(ready)状态;
- sleep()方法声明抛出 InterruptedException,而 yield()方法没有声明任何异常;
- sleep()方法比 yield()方法(跟操作系统 CPU 调度相关)具有更好的可移植性。
1.18.2.2 实现Runnable接口
1.18.2.2.1步骤
- 定义Runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。
- 创建Runnable实现类的实例,并以此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。
- 调用线程对象的start()方法来启动线程。
//创建自定义类对象 线程任务对象
MyRunnable mr = new MyRunnable();
//创建线程对象
Thread t = new Thread(mr);
t.start();
1.18.2.3 实现Callable 接口
参考:
6.实现 Callable 接口_海洋的渔夫的博客-CSDN博客_实现callable接口
1.18.3线程安全-线程同步
1.18.3.1线程同步机制
多个线程对临界资源的共享,一个线程对多条语句只执行了一部分,还没有执行完,另一个线程参与进来执行,会造成操作的不完整性,导致破坏共享数据。
为了避免这种问题,Java引入线程同步,synchronized。
实现同步的方式:同步代码块、同步方法、Lock锁
1.18.3.2同步代码块
synchronized
关键字可以用于方法中的某个区块中,表示只对这个区块的资源实行互斥访问。
格式
synchronized(同步锁){
需要同步操作的代码
}
1、操作共享数据的代码,即为需要被同步的代码。同步代码块里的代码不能包含多了,也不能包含少了。
2、共享数据:多个线程共同操作的变量。
3、同步监视器,俗称:锁。任何一个类的对象,都可以充当锁。要求:多个线程必须要共用同一把锁。
4、在实现Runnable接口创建多线程的方式中,我们可以考虑使用 this 充当同步监视器。
5、在继承Thread类创建多线程的方式中,慎用this充当同步监视器,考虑使用当前类(类.class)充当同步监视器。
示例
/**
* 同步方式解决线程安全问题
* 例子:三个窗口卖票,总票数为100张 ,继承Thread方式
* 当一个线程A在操作ticket的时候,其他线程不能参与进来,直到线程A操作完ticket时,其他线程才可以
开始操作ticket
//方式一:同步代码块
class MyThread extends Thread {
private static Object obj = new Object(); //注意这里要加static,保证多个对象共用一个obj
private static int ticket = 100;
@Override
public void run() {
while (true) {
//不能用this,因为每New 一个MyThread,this都指向新的一个对象,不唯一
//方式一: synchronized (obj) ,锁用obj,obj对象唯一。
//方式二: 类.class。synchronized (MyThread.class) ,MyThread.class只会加载一次
synchronized (MyThread.class) {
if (ticket > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(getName() + ": 卖票,票号为: " + ticket--);
} else
break;
}
}
}
}
public class Main {
public static void main(String[] args) {
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
t1.setName("窗口一:");
t2.setName("窗口二:");
t3.setName("窗口三:");
t1.start();
t2.start();
t3.start();
}
}
1.18.3.3同步方法
如果操作共享数据的代码被完整的声明在一个方法中,并将此方法声明为同步的。
同步方法中锁
- 对于非static方法,同步锁是this。
- 对于static方法,同步锁是使用当前方法所在类的字节码对象(类名.class)。
public class MyRunnable implements Runnable{
//定义一个多个线程共享的票源
private static int ticket = 100;
//卖票
@Override
public void run() {
System.out.println("this:"+this);//this:com.itfxp.Synchronized.RunnableImpl@58ceff1
while(true){
payTicketStatic();
}
}
/*
静态的同步方法
锁对象是谁?
不能是this
this是创建对象之后产生的,静态方法优先于对象
静态方法的锁对象是本类的class属性-->class文件对象(反射)
*/
public static /*synchronized*/ void payTicketStatic(){
synchronized (RunnableImpl.class){
//先判断票是否存在
if(ticket>0){
//提高安全问题出现的概率,让程序睡眠
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//票存在,卖票 ticket--
System.out.println(Thread.currentThread().getName()+"-->正在卖第"+ticket+"张票");
ticket--;
}
}
}
/*
定义一个同步方法
同步方法也会把方法内部的代码锁住
只让一个线程执行
同步方法的锁对象是谁?
就是实现类对象 new RunnableImpl()
也是就是this
*/
public /*synchronized*/ void payTicket(){
synchronized (this){
//先判断票是否存在
if(ticket>0){
//提高安全问题出现的概率,让程序睡眠
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//票存在,卖票 ticket--
System.out.println(Thread.currentThread().getName()+"-->正在卖第"+ticket+"张票");
ticket--;
}
}
}
}
-------------------测试类--------------------
public class Test {
public static void main(String[] args) {
//创建Runnable接口的实现类对象
MyRunnable run = new MyRunnable ();
//创建Thread类对象,构造方法中传递Runnable接口的实现类对象
Thread t0 = new Thread(run,"窗口1");
Thread t1 = new Thread(run,"窗口2");
Thread t2 = new Thread(run,"窗口3");
//调用start方法开启多线程
t0.start();
t1.start();
t2.start();
}
}
1.18.3.4Lock锁
java.util.concurrent.locks.Lock
机制提供了比synchronized代码块
和synchronized方法
更广泛的锁定操作,
同步代码块/同步方法具有的功能Lock都有,除此之外更强大,更体现面向对象。
方法
方法名 | 说明 |
---|---|
public void lock() | 加同步锁。 |
public void unlock() | 释放同步锁。 |
synchronized 与 Lock的异同
synchronized | Lock | |
相同 | 二者都可以解决线程安全问题 | |
不同 | synchronized 机制在执行完相应的同步代码以后,自动的释放同步监视器 | Lock 需要手动的启动同步 lock(),同时结束同步也需要手动的实现 unlock() |
优先使用顺序 | Lock → 同步代码块(已经进入了方法体,分配了相应资源 ) → 同步方法(在方法体之外) |
/*
卖票案例出现了线程安全问题
卖出了不存在的票和重复的票
解决线程安全问题的三种方案:使用Lock锁
java.util.concurrent.locks.Lock接口
Lock 实现提供了比使用 synchronized 方法和语句可获得的更广泛的锁定操作。
Lock接口中的方法:
void lock()获取锁。
void unlock() 释放锁。
java.util.concurrent.locks.ReentrantLock implements Lock接口
使用步骤:
1.在成员位置创建一个ReentrantLock对象
2.在可能会出现安全问题的代码前调用Lock接口中的方法lock获取锁
3.在可能会出现安全问题的代码后调用Lock接口中的方法unlock释放锁
*/
public class RunnableImpl implements Runnable{
//定义一个多个线程共享的票源
private int ticket = 100;
//1.在成员位置创建一个ReentrantLock对象
Lock l = new ReentrantLock();
//设置线程任务:卖票
@Override
public void run() {
//使用死循环,让卖票操作重复执行
while(true){
//2.在可能会出现安全问题的代码前调用Lock接口中的方法lock获取锁
l.lock();
//先判断票是否存在
if(ticket>0){
//提高安全问题出现的概率,让程序睡眠
try {
Thread.sleep(10);
//票存在,卖票 ticket--
System.out.println(Thread.currentThread().getName()+"-->正在卖第"+ticket+"张票");
ticket--;
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
//3.在可能会出现安全问题的代码后调用Lock接口中的方法unlock释放锁
l.unlock();//无论程序是否异常,都会把锁释放
}
}
}
}
}
1.18.4死锁
多个进程可以竞争有限数量的资源。当一个进程申请资源时,如果这时没有可用资源,那么这个进程进入等待状态。有时,如果所申请的资源被其他等待进程占有,那么该等待进程有可能再也无法改变状态。这种情况称为死锁。
1.18.4.1死锁特征
1.18.4.1.1必要条件
如果在一个系统中以下四个条件同时成立,那么就能引起死锁:
- 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
- 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
- 循环等待条件:在发生死锁时,必然存在一个进程–资源的环形链。
我们强调所有四个条件必须同时成立才会出现死锁。循环等待条件意味着占有并等待条件,这样四个条件并不完全独立。
1.18.4.1.2预防死锁
- 资源一次性分配:一次性分配所有资源,这样就不会再有请求了:(破坏请求条件)
- 只要有一个资源得不到分配,也不给这个进程分配其他的资源:(破坏请保持条件)
- 可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)
- 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)
1.18.4.1.3避免死锁
预防死锁的几种策略,会严重地损害系统性能。因而,系统在进行资源分配之前预先计算资源分配的安全性。若此次分配不会导致系统进入不安全的状态,则将资源分配给进程;否则,进程等待。其中最具有代表性的避免死锁算法是银行家算法。
1.18.5乐观锁、悲观锁
代码中:比如多个线程需要同时操作修改共享变量,这时需要给变量上把锁(syncronized),保证变量值是对的。
数据库表:当多个用户修改表中同一数据时,我们可以给该行数据上锁(行锁)。
1.18.5.1悲观锁(悲观并发控制)
悲观锁:指的是在操作数据的时候比较悲观,悲观地认为别人一定会同时修改数据,因此悲观锁在操作数据时是直接把数据上锁,直到操作完成之后才会释放锁,在上锁期间其他人不能操作数据。
数据库中的行锁,表锁,读锁,写锁,以及 syncronized 实现的锁均为悲观锁。
场景
线程A:扣除001账号500元
线程B:扣除001账号100
此时线程 A 操作数据表001号前先给编号为001的这行数据加上悲观锁(行锁)。此时这行数据只能 A 来操作,也就是只有 A 能操作。线程B 想操作就必须一直等待。当 A 处理完,B 再扣除001号账号时会发现001账号余额为0,就放弃操作。
1.18.5.2乐观锁
指的是在操作数据的时候非常乐观,乐观地认为别人不会同时修改数据,因此乐观锁默认是不会上锁的,只有在执行更新的时候才会去判断在此期间别人是否修改了数据(利用version更新来判断),如果别人修改了数据则放弃操作,否则执行操作。
冲突比较少的时候, 使用乐观锁(没有悲观锁那样耗时的开销) 由于乐观锁的不上锁特性,所以在性能方面要比悲观锁好,比较适合用在DB的读大于写的业务场景。
1.18.5.2.1版本号机制、CAS
乐观锁一般会使用版本号机制或CAS(Compare-and-Swap,即比较并替换)算法实现。
1.18.5.2.2版本号机制
乐观锁认为数据修改产生冲突的概率并不大,多个线程在修改数据的之前先查出版本号,在修改时把当前版本号作为修改条件,只会有一个线程可以修改成功,其他线程则会失败。
线程A和B同时查询001的数据,并保存数据库中相应的数据信息(余额:500、version:0 等)
假设A先操作数据库,A执行 update 账户 set 余额-=500,version+=1 where 编号=001;
将 id=001 和 version=0 作为条件进行数据更新,余额-500,并将version +1。A操作完后,数据库信息如下所示
之后B操作数据库 执行update 账户 set 余额-=100,version+=1 where 编号=001; 将 id=001 和 version=0作为条件进行数据更新,试图向数据库提交数据,但此时比对数据库记录版本时发现,B提交的version为1,数据库当前版本也是1,不满足 “ 提交版本必须大于记录当前版本才能执行更新 “ 的乐观锁策略,因此,B 的提交被驳回。
1.18.5.2.3CAS
CAS: compare and swap,(比较与交换)。
CAS操作包括了三个操作数:
- 分别是需要读取的内存位置(V)
- 进行比较的预期值(A)
- 拟写入的新值(B)
操作逻辑是,如果内存位置V的值等于预期值A,则将该位置更新为新值B,否则不进行操作。另外,许多CAS操作都是自旋的,意思就是,如果操作不成功,就会一直重试,直到操作成功为止。
面试必备之深入理解自旋锁_JavaGuide的博客-CSDN博客_自旋锁
1.18.5.3乐观锁的缺点
1.18.5.3.1 ABA 问题
假设变量V初次读取的是A值,之后赋值时检查到它仍然是A值,这能代表这个数据没有被修改过吗?不能,因为在这段时间值可能被改为其他值,然后又改回A,但是CAS误以为它没有被修改过,这个问题被称为CAS操作的 "ABA"问题。
JDK 1.5 以后的 AtomicStampedReference 类
就提供了此种能力,其中的 compareAndSet 方法
就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
1.18.5.3.2循环时间长开销大
自旋CAS会产生一个问题,不成功就一直循环执行直到成功。如果长时间不成功,会给CPU带来非常大的执行开销。
1.18.5.3.3只能保证一个共享变量的原子操作
CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5开始,提供了AtomicReference类
来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类
把多个共享变量合并成一个共享变量来操作。
1.18.5.4选择
- 乐观锁适用于读多写少的场景,可以省去频繁加锁、释放锁的开销,提高吞吐量。
- 在写比较多的场景下,乐观锁会因为版本不一致,不断重试更新,产生大量自旋,消耗 CPU,影响性能。这种情况下,适合悲观锁。
1.18.6Java主流的锁
由于内容太多暂未整理,等待后续更新
参考:Java中常见的各种锁(非常全)_JAVA_NO.1的博客-CSDN博客_java 中的锁
Java主流锁_Lin_XXiang的博客-CSDN博客_java主流锁
1.18.7线程间通信
1.18.7.1什么是线程间通信
多个线程在处理同一个资源,但是处理的动作(线程的任务)却不相同 。
那么为什么要有线程间通信呢?
多个线程并发执行时, 在默认情况下CPU是随机切换线程的,当我们需要多个线程来共同完成一件任务,并且我们希望他们有规律的执行, 那么多线程之间需要一些协调通信,以此来帮我们达到多线程共同操作一份数据。
1.18.7.2等待唤醒机制
1.18.7.2.1概念
多个线程在处理共享资源,并且任务不同时,需要线程通信来帮助解决线程之间对共享资源的使用或操作, 避免对同一共享资源的争夺。我们需要通过一些机制使的各个线程能有效的利用资源,等待唤醒机制就是一种。
在一个线程完成操作后,会进入等待状态(wait()), 等待其他线程执行完后再将其唤醒(notify());在有多个线程进行等待时,可以使用 notifyAll()来唤醒所有的等待线程。
wait/notify 就是线程间的一种协作机制。
1.18.7.2.2方法
方法名 | 说明 |
---|---|
public final void wait() | 在其他线程调用此对象的 notify() 方法或 notifyAll() 方法前,导致当前线程等待 |
public final void notify() | 唤醒在此对象监视器上等待的单个线程 |
public final void notifyAll() | 唤醒在此对象监视器上等待的所有线程 |
wait:
线程不再活动,不再参与调度,进入 wait set 中,因此不会浪费 CPU 资源,也不会去竞争锁了,这时的线程状态即是 WAITING。直到收到“通知(notify)”在这个对象上等待的线程从wait set 中释放出来,重新进入到调度队列(ready queue)中。
notify
:则选取所通知对象的 wait set 中的一个线程释放,进入调度队列,就绪状态,等待获取对象锁后运行,获取锁对象后,线程才从 WAITING 状态变成 RUNNABLE 状态 。
notifyAll
:则释放所通知对象的 wait set 上的全部线程。
需要格外注意的是:
1.wait方法与notify方法必须要由同一个锁对象调用
对应的锁对象可以通过notify唤醒使用同一个锁对象调用的wait方法后的线程。
2.wait方法与notify方法是属于Object类的方法的
锁对象可以是任意对象,而任意对象的所属类都是继承了Object类的。
3.wait方法与notify方法必须要在同步代码块或者是同步函数中使用
必须要通过锁对象调用这2个方法。
1.18.7.3生产者消费者问题
等待唤醒机制其实就是经典的“生产者与消费者”的问题。
生产者和消费者共用同一个存储空间,如图所示,生产者生产并放入共享空间中,而消费者消费,
但有可能出现死锁问题:
存储空间已满,而生产者占用着它,消费者等着生产者让出空间从而去消费产品,生产者等着消费者消费产品,进而再生产产品。互相等待,从而发生死锁。
示例
public class Main {
public static void main(String[] args) {
ShareBuffer c = new ShareBuffer();
Producer p1 = new Producer(c, 1);
Consumer c1 = new Consumer(c, 1);
p1.start();
c1.start();
}
}
//定义共享缓冲池
class ShareBuffer {
//设置缓冲池大小
private int contents;
//设置一个flag:true表示是否可以操作缓冲池
private boolean available = false;
//同步方法:get
public synchronized int get() {
//当缓冲池不可操作时,线程等待
while (available == false) {
try {
wait();
}
catch (InterruptedException e) {
}
}
//如果可以操作缓冲池,则先将标记available设置为false,并唤醒线程
available = false;
notifyAll();
return contents;
}
public synchronized void put(int value) {
while (available == true) {
try {
wait();
}
catch (InterruptedException e) {
}
}
contents = value;
available = true;
notifyAll();
}
}
class Consumer extends Thread {
private ShareBuffer ShareBuffer;
public Consumer(ShareBuffer c) {
ShareBuffer = c;
}
public void run() {
int value = 0;
for (int i = 0; i < 10; i++) {
value = ShareBuffer.get();
System.out.println("消费者消费: " + value);
}
}
}
class Producer extends Thread {
private ShareBuffer ShareBuffer;
public Producer(ShareBuffer c) {
ShareBuffer = c;
}
public void run() {
for (int i = 0; i < 10; i++) {
ShareBuffer.put(i);
System.out.println("生产者生产: " + i);
try {
sleep((int)(Math.random() * 100));
} catch (InterruptedException e) { }
}
}
}
运行结果
当然这里的都是一些简单的示例,生产者消费者问题还涉及到,先标记后进入、生产者多消费者问题等等,这些涉及到操作系统原理,以后该博客会相继出操作系统、数据结构、计算机组成原理、计算机网络等计算机基础系列。
1.18.7.4wait与sleep的区别
- 提供方不同:sleep是Thread类提供的方法,wait是由Object提供的方法
- sleep方法是静态方法由Thread直接调用,wait方法由锁对象调用
- sleep方法可以在任意线程时使用,wait方法只能在线程同步中使用
- sleep方法不会释放锁资源,wait方法会释放锁资源
- sleep方法达到指定时间后自动唤醒,wait方法可以自动唤醒也可以手动唤醒
1.18.8线程池
1.18.8.1概念
多线程中如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要消耗cpu资源。
线程池就是一个容纳多个线程的容器,其中的线程可以反复使用。
1.18.8.2线程池优点
降低资源消耗
。减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务。提高响应速度
。任务可以不需要的等到线程创建就能立即执行。提高线程的可管理性
。可以根据系统的承受能力,调整线程池中工作线线程的数目,防止因为消耗过多的内存
1.18.8.3线程池的使用
线程池创建方式可分为 2 类,创建⽅法共有 7 种
1. 通过 ThreadPoolExecutor 创建的线程池;
2. 通过 Executors 创建的线程池。
Java里面线程池的顶级接口是java.util.concurrent.Executor
,但是严格意义上讲Executor
并不是一个线程池,而只是一个执行线程的工具。真正的线程池接口是java.util.concurrent.ExecutorService
。
官方建议使用Executors类来创建线程池对象。
1.18.8.3.1 Executors类创建的线程池
Executors类创建线程池的方法如下
newCachedThreadPool | 用来创建一个可以无限扩大的线程池,适用于负载较轻的场景,执行短期异步任务。(可以使得任务快速得到执行,因为任务时间执行短,可以很快结束,也不会造成cpu过度切换) |
newFixedThreadPool | 创建一个固定大小的线程池,因为采用无界的阻塞队列,所以实际线程数量永远不会变化,适用于负载较重的场景,对当前线程数量进行限制。(保证线程数可控,不会造成线程过多,导致系统负载更为严重) |
newSingleThreadExecutor | 创建一个单线程的线程池,适用于需要保证顺序执行各个任务 |
newScheduledThreadPool | 适用于执行延时或者周期性任务。 |
public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("我需要一个老师");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("老师来了: " + Thread.currentThread().getName());
System.out.println("辅导作业,结束,老师回到了办公室池");
}
}
public class ThreadPool{
public static void main(String[] args) {
// 创建线程池对象
ExecutorService service = Executors.newFixedThreadPool(2);//包含2个线程对象
// 创建Runnable实例对象
MyRunnable r = new MyRunnable();
//自己创建线程对象的方式
// Thread t = new Thread(r);
// t.start(); ---> 调用MyRunnable中的run()
// 从线程池中获取线程对象,然后调用MyRunnable中的run()
service.submit(r);
// 再获取个线程对象,调用MyRunnable中的run()
service.submit(r);
service.submit(r);
// 注意:submit方法调用结束后,程序并不终止,是因为线程池控制了线程的关闭。
// 将使用完的线程又归还到了线程池中
// 关闭线程池
//service.shutdown();
}
}
1.18.8.3.2 其他方式创建线程池
线程池的使用(7种创建方法)_Youcan.的博客-CSDN博客_线程池的使用
1.18.9 volatile关键字
1.18.9.1 内存模型
CPU负责运算,涉及到数据读写,由于CPU每次都要向主内存中读写数据,而且CPU运算速度特别快,内存读写速度相对较低,从而导致整体效率降低。因此采用高速缓存来调和CPU与内存的速度问题。程序运行时,会从内存中复制一份数据到高速缓存中,然后CPU在高速缓存中读写数据,计算完成后,再将结果从高速缓存中返回到主内存里。
在多线程时,会出现一些问题
现有A、B两个线程都执行sum=sum+1,sum初始值为0,正常来说结果应为2。
线程A和B都主内存中读数据到高速缓存中,之后在CPU中运算
线程A先执行sum=0+1,之后将sum=1返回到内存中。线程B执行sum=0+1,之后将sum=1返回到内存中,覆盖线程A的结果,最后结果为1
一个变量在多个CPU中都有缓存就会出现:缓存不一致问题
我们来分析多线程之所以出现缓存不一致问题?
主要就是,当线程A执行完sum=sum+1后sum=1存入内存后,线程B的sum应该可以立即更新为1
1.18.9.2 volatile
为了解决Java缓存不一致和重排序问题,于是有了volatile 关键字。
volatile保证线程间变量的可见性,简单地说就是当线程A对变量X进行了修改后,在线程A后面执行的其他线程能看到变量X的变动,更详细地说是要符合以下两个规则:
- 线程对变量进行修改之后,要立刻回写到主内存。
- 线程对变量读取的时候,要从主内存中读,而不是缓存。
各线程的工作内存间彼此独立,在线程启动的时候,虚拟机为每个线程工作的内存分配一块工作内存,里面包含线程内部定义的局部变量和线程所需要使用的共享变量(非线程内构造的对象)的副本,即为了提高执行效率。
volatile是不错的机制,但是volatile不能保证原子性。
volatile强制保证变量在线程间的可见性,但是在资源充足的情况下,jvm会尽量保证数据的准确性
示例
/**
* volatile用于保证数据的同步,也就是可见性
*/
public class VolatileTest {
private volatile static int num = 0;
public static void main(String[] args) throws InterruptedException {
new Thread(
new Runnable(){
public void run(){
while(num==0) { //此处不要编写代码
}
}
}
) .start();
Thread.sleep(1000);
num = 1;
}
}
volatile 不能保证原子性,它保证了内存的可见性和一定程度上的有序性
一定程度上的有序性是什么意思?
就是说加了volatile 修饰的变量,对它的值进行修改的代码行相对位置不会变,不会受到指令重排序的影响
比如:
a=1
int b=2
volatile int sum=3
int c=4
int d=5
a,b,c,d没有被volatile 关键字修饰,而sum被voliate关键字修饰了
那么sum的位置一定会在a,b和c,d之间夹着。但是a,b的顺序可能调换,c,d的顺序也可能调换,这就是一定程度上的有序性。
1.18.9.3 深入理解volatile
1.18.9.3.1 Java 中能创建 volatile 数组吗?
Java 中可以创建 volatile 类型数组,不过只是一个指向数组的引用,而不是整个数组。如果改变引用指向的数组,将会受到 volatile 的保护, 但是如果多个线程同时改变数组的元素,volatile 标示符就不能起到之前的保护作用了。
1.18.9.3.2 volatile 能使得一个非原子操作变成原子操作吗?
一个典型的例子是在类中有一个 long 类型的成员变量。如果你知道该成员变量会被多个线程访问,如计数器、价格等,最好是将其设置为 volatile。
为什么?
首先复习一下八大基本类型
类型 | 字节 | |
整型 | byte | 1 |
short | 2 | |
int | 4 | |
long | 8 | |
浮点 | float | 4 |
double | 8 | |
字符 | char | 不确定 |
布尔 | boolean | 1 |
我们都知道,java中对一些基本类型是原子操作,但是对所有的基本类型都是原子操作么?
以前的jvm虚拟机是32位,但是long、double类型是64位,根本没法原子操作,因为32位jvm处理64位要分两步,每次处理32位。因此需要通过 volatile来修饰。对一个 volatile 型的 long 或 double 变量的读写是原子。
但是随着技术的更新现在已经出现了64位jvm,对于long 或 double 变量的读写是原子。不再需要volatile修饰
1.18.9.3.3 volatile的实践
一种实践是用 volatile 修饰 long 和 double 变量,使其能按原子类型来读写。 double 和 long 都是 64 位,因此对这两种类型的读是分为两部分的,第一次 读取第一个 32 位,然后再读剩下的 32 位,这个过程不是原子的,但 Java 中 volatile 型的 long 或 double 变量的读写是原子的。
volatile 修饰符的另一个 作用是提供内存屏障(memory barrier),例如在分布式框架中的应用。简单的 说,就是当你写一个 volatile 变量之前,Java 内存模型会插入一个写屏障(write barrier),读一个 volatile 变量之前,会插入一个读屏障(read barrier)。意 思就是说,在你写一个 volatile 域时,能保证任何线程都能看到你写的值,同时, 也能保证任何数值的更新对所有线程是可见的,因为内存屏障会将其 他所有写的值更新到缓存。