其实多线程还有很多的东西要说,我们慢慢来,可能会有一些东西没说到,那就没办法了,只能说尽量吧!
1.synchronized关键字
说到多线程肯定离不开这个关键字,为什么呢?因为多线程之间虽然有各自的栈和PC计数器,但是也有一些区域是共享的(堆和方法区),这些共享的区域就不可避免的造成一些问题,比如一个线程对共享区的一个变量进行修改时,此时另外一个线程也要对这个数据进行修改,就会出现同步问题,到底是以哪个线程为主呢?
最常见的可能就是银行转账了,假如我就100块,我要向朋友小明转账100块,由于转账可能需要一些时间,所以这个时候我的账户余额显示的还是100块!于是我趁着这段时间立马又向小红转账100块,也会成功,于是最后我的账户余额会变成负100块,这显然就是不对的。
于是我们可以用.synchronized关键字来修饰这个方法,让这个方法在多线程的情况下,一次只能被一个线程操作,在这个线程用完这个方法之前,其他的线程不可以调用这个方法,这就是锁;
你想一下,你回到你自己的房间里就把们反锁了,别人肯定就进不来了啊!
.关于synchronized关键字的用法大概就几种
1.1.同步方法
同步方法很显然是加在方法上面,注意位置,要放在返回类型前面:
1.2.同步代码块
我们可以用synchronized将方法中的代码都包起来,这种和上面那种是等同的;
但是有的时候我们不想整个方法都用synchronized包起来,因为这样的效率很低(注意,synchronized关键字修饰的区域越小效率越高),我们可以把其中一些主要的逻辑给包起来:
1.3.分析
我们说一说那个this代表什么意思,也可改成其他的么?当然可以改成其他的,类型随意,随便什么都行,字符串,对象,或者User.class等等,那到底有什么用呢?
怎么说呢,我就说说我的理解吧!我将synchronized(){}这样的看作是一把锁,而括号里面的this就是锁芯,假如有两个锁芯相同的锁,那么钥匙肯定是一样的!
根据这个道理,我们说说多线程调用这个代码块的规则,首先,一个程序中有很多的同步代码块(也就是锁),每一个锁都对应有一个锁芯,有可能多个锁的锁芯相同;而每个线程默认拥有全部锁的钥匙,这个时候一个线程打开一把锁之后进去,立刻把门反锁锁死,而且这个反锁比较牛逼,能把所有相同锁芯的门都反锁锁死,其他线程即使拥有这种锁芯的钥匙也是打不开的,但是可以打开其他类型的锁;
反正我是这样理解的,如果你有更好的理解方式最好用自己的理解方式;
顺便看一看下面的的简单代码:
package com.wyq.thread; public class Bank {
public void toMoney(int money){
synchronized (this) {
System.out.println("转账金额:"+money); try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
} } }
public void save(int num){
synchronized (this) {
System.out.println("存钱:"+num);
} } public static void main(String[] args) {
Bank bank = new Bank(); new Thread(new Runnable() { @Override
public void run() {
bank.toMoney(100);
}
}).start(); new Thread(new Runnable() { @Override
public void run() {
bank.save(200);
}
}).start(); }
}
你们觉得执行的结果怎么是什么?答案:由于这两个锁的锁芯是一个型号的,所以先是转账方法执行,执行完毕之后才是存钱方法
现在我们把存钱方法的锁芯换成Bank.class试试效果,可以看到这两个互不影响;
这说明了一个线程执行一个同步代码块时,该类中的其他锁芯相同的同步代码块就会被锁死;但是其他锁芯的方法还是可以正常使用;
但是你们有没有想过假如把synchronized关键字加到静态方法上会怎么样呢?有兴趣的可以试试,我直接说一下结论,下面两种是等效的:
2.线程的生命周期
前面我们说了这么多都是说的多线程的用法,我一直的理念就是学新知识先不要看概念什么的,先会熟练运用,用多了再理解概念会很深刻1
我们就随便看看线程的生命周期吧!
我们每次都是调用xxx.start()方法表示本线程已经准备就绪,CPU可以随时的过来运行这个线程,难道线程一创建就直接是准备就绪的吗?话说线程是什么时候创建的啊?是是在new Thread()的时候?还是调用start()方法得时候?难道是CPU来调用这个线程的时候再创建线程吗?emmmm,是不是感觉比较模糊啊,那么接下来我们就简单看看一个线程从创建到死亡到底经历了哪些阶段?
线程从出生到死亡分为五个状态,我们根据图来看看五个状态分别是干什么的:
新建(NEW):注意:我们在代码中 new Thread(xxx)的时候只是创建一个普通的java对象,还没有创建线程,只有调用的start()方法的时候jvm才会真正的开始新建线程;
准备就绪(Runnable):调用start()方法线程也创建了之后,此线程不会马上被CPU调用,会进入准备就绪状态等待CPU过来;在start()方法内部才是真正开始创建线程!!!
运行(Running):当CPU调用这个线程的时候这个线程就处于运行状态,并且开始执行run()方法内部的逻辑
阻塞(Blocked):正处于运行状态的线程由于一些原因被终止了,线程就进入阻塞状态,也可以我们人为的让线程阻塞!注意:阻塞状态下的线程无法被CPU在此调用,除非想办法让阻塞状态变成准备就绪状态才有可能被CPU调用。
死亡(Terminated):线程死亡,要么是线程run()方法正常执行完毕,要么就是执行这个线程的时候出现了什么问题*死亡。。。
随意看看start()方法的源码,非常少,很好理解:
public synchronized void start() {
//线程新创建时threadStatus=0,这里是判断当前线程是不是新建的,如果不是,就抛出一个异常
if (threadStatus != 0)
throw new IllegalThreadStateException();
//将此线程添加到组中,这个组的类型是ThreadGroup,其实在这个组里面就是维持了一个Thread[]数组,用于保存我们将要运行的线程
group.add(this);
boolean started = false;
try {
//调用下面的那个本地方法,并设置运行的标志为true
start0();
started = true;
} finally {
try {
if (!started) {
//假如线程运行的标志设置失败,就抛出线程开始失败异常
group.threadStartFailed(this);
}
} catch (Throwable ignore) { }
}
}
//这是一个本地方法也就是JNI方法,用C++实现的,所以我们这里看不到任何实现,但是可以猜想这里面会创建线程,分配线程的内存空间,
//然后就是等待CPU的调度,内部就会调用我们创建线程的run()方法
private native void start0();
由于本人对C++不熟悉,那个JNI的方法源码就不献丑了!假如有小伙伴对那个JNI方法很有兴趣的话,可以参考一个老哥的博客: https://www.jianshu.com/p/81a56497e073,没兴趣的话就算了。。。
其实我感觉分析到了这一步就差不多了,稍微提一下,看了start()方法的源码,问一个很有趣的问题,假如我们创建线程的时候多次调用start()方法会怎么样呢?例如下面:
答案是,多次调用start()方法就会报错,看上面源码的第一个注释那里,试想一下,对于同一个Thread对象,第一次调用的start()方法之后线程可能就被CPU调用了,就不再是新建状态了,我们再进行start()方法肯定就会抛异常啊!异常信息我也截一下图看看:
但是啊,假如要改的话怎么改呢?要么就把for循环去掉,要么for循环就把Thread thread = new Thread(xxx)这一段东西也包含进去,使得每一次都是新建一个线程去调用start()方法,那就不会报错了!
3.JMM和JVM的区别
这两个很像,但是对于新手来说第一次看到这两个还真是懵懵分不清楚,首先jvm指的是java虚拟机(我们一般也叫做jvm的内存模型),一般指的是jvm组成部分,其实就是我们前面说的那几个部分组成,java栈,java堆,方法区等等,这里就不多说了;
那么JMM又是一个什么鬼呢?我们看一句话:Java线程之间的通信由Java内存模型(简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见!这句话的意思就是对于多线程来说,线程之间的通信就是由JMM控制,而JMM是一个java内存模型,其实就是线程对共享区数据读取和写入的一个模型!
我随便借了一张java内存模型(简称JMM)示意图:
方便我们理解,我们可以把主内存看作jvm中的共享区(java堆+方法区),假如我们创建的一个线程要从共享区中的数据进行修改,看一下一个简单的例子:
想一想为什么不是按照顺序打印的啊?你看,还有的打印居然重复了,有两个0,这真是日了狗了,按理来说创建了20个线程,每一个线程都会拿到i进行+1错作,并打印,每个数字应该只会出现一次啊!
不是按照顺序打印的很好理解,因为线程的执行顺序是CPU随机调度的嘛,但是重复的数据就不能理解了,于是这就要看看我们上面的JMM模型了;
首先是每个线程都有自己的私有内存空间(栈+PC计数器),其实还有一个私有的空间叫做线程的工作空间,这其实就是起到一个缓存的作用,因为CPU在调度线程的时候由于CPU运算速度太快,从内存中读取的话也很慢,很浪费时间,于是有了缓存这个作为CPU和内存之间的缓冲!
假如一个线程要从共享区拿到数据,首先会将这个数据复制一份到这个线程的工作空间,然后CPU调用这个线程的时候其实就是CPU对这个线程工作空间的数据进行运算,将运算后的结果覆盖工作空间原来的值,等这个线程进入死亡状态的时候,线程工作空间的值就会回写到主存中覆盖原有的值。
是不是觉得一切都很好,然而这里却很有问题,假如线程A将共享区的变量I=0复制到线程A的工作空间,然后CPU操作,对i=i+1,此时i=1但是由于线程还有其他逻辑要处理,还没有来得及写入到共享区覆盖原有值0,另外一个线程B也创建了,并且也将共享区的i=0读到B线程的内存空间,然后也对i=i+1,此时i也是等于1,然后线程A、B都将自己的值写入共享区覆盖原来的i,此时共享区中i=1;很坑,明明进行了两个线程的错作,i的值却只是增加了一次;
要怎么解决这个问题呢?这里简单介绍两种方式,第一种就是前面说的synchronized关键字,我们可以看看效果:
第二种方案是用volatile关键字,这个关键字修饰一个成员变量,存放在共享区,这个成员变量就相当于暴露在所有的线程眼中,只要有线程对这个变量进行什么修改,所有的线程都会知道,然后也会相应的进行修改;
举个例子:一个变量被volatile修饰,假如有一个线程读取了这个变量的值,并在该线程的工作空间中进行了修改,那么马上就会回写到共享区,其他线程如果也要用到这个共享区变量,就要直接从从共享区中拿,这样可以保证拿到的数据始终都是最新的,当然这样做其实就是相当于去掉了线程工作区间的缓存作用,所以会影响性能。。。。
我们看看一个例子:
注意:从这里开始可能会有点不好理解了,友军可以跳过!!!
那么我们就要问了,这个线程的工作空间在哪里呢?是不是在线程私有空间的栈中还是PC计数器中呢?
答案是:没有这个所谓的线程工作空间,这是一个虚拟模型,实际系统中并没有直接的对应;我找了很多的资料,我也是看的云里雾里,我把那些资料给大家看看,看过就好,不要深究;
答案1:“工作内存”是一个虚拟模型,实际系统中并没有直接的对应。java官方文档中也没有“工作空间”这个概念,应该是有人为了让读者理解java支持的非常松散的内存一致性模型才提出来的。有点误人子弟。这个“工作内存”是各种CPU架构支持的内存模型跟编译器的各种优化而产生的一个效果,并没有工作内存跟主内存相互拷贝的实际动作;
答案2:“工作缓冲区”是一个抽象的概念,JVM只是规范了主存和线程内存的变量访问时候的需要满足的规范(如可见性等),并没有对这个缓冲区做实现上的限制。也就是说,把共享变量从主存拷贝到线程的工作内存后,具体放在哪里,取决于具体的虚拟机实现,只要满足JMM规范,至于具体放在哪里(寄存器,内存Cache等等),其实也没关系的;
答案3:对于JMM和JVM本身的内存模型,这两者并没有关系;如果一定要对应,那就从变量、主内存、工作空间的定义来看,主内存主要定义java堆中对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域;从更低的层次来看,主内存就是物理内存,而为了获取更好的执行速度,虚拟机(甚至硬件系统本身的优化措施)可能会让工作内存由于存储与寄存器和高速缓存中,因为运行时主要访问的是工作内存
总结:
估计后面会继续说说线程,还有好多东西啊,比如怎么人为的去将线程由运行状态变为阻塞状态,然后想办法再把阻塞状态变为准备就绪状态,还有其他的各种锁,以及一些概念性的东西,慢慢来吧!
假如有小伙伴想看书学习多线程的话,可以参看一本叫做《JAVA多线程设计模式》,电子档链接:https://pan.baidu.com/s/1ng_bAGE-ieNUZoczFHCnJA 提取码:sqz3