线程是程序执行的最小单元,多线程是指程序同一时间可以有多个执行单元运行(这个与你的CPU核心有关)。
在java中开启一个新线程非常简单,创建一个Thread对象,然后调用它的start方法,一个新线程就开启了。
那么执行代码放在那里呢?有两种方式:1. 创建Thread对象时,复写它的run方法,把执行代码放在run方法里。2. 创建Thread对象时,给它传递一个Runnable对象,把执行代码放在Runnable对象的run方法里。
如果多线程操作的是不同资源,线程之间不会相互影响,不会产生任何问题。但是如果多线程操作相同资源(共享变量),就会产生多线程冲突,要知道这些冲突产生的原因,就要先了解java内存模型(简称JMM)。
一. java内存模型(JMM)
1.1 java内存模型(JMM)介绍
java内存模型决定一个线程对共享变量的写入何时对另一个线程可见。从抽样的角度来说:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。
存在两种内存:主内存和线程本地内存,线程开始时,会复制一份共享变量的副本放在本地内存中。
线程对共享变量操作其实都是操作线程本地内存中的副本变量,当副本变量发生改变时,线程会将它刷新到主内存中(并不一定立即刷新,何时刷新由线程自己控制)。
当主内存中变量发生改变,就会通知发出信号通知其他线程将该变量的缓存行置为无效状态,因此当其他线程从本地内存读取这个变量时,发现这个变量已经无效了,那么它就会从内存重新读取。
1.2 可见性
从上面的介绍中,我们看出多线程操作共享变量,会产生一个问题,那就是可见性问题: 即一个线程对共享变量修改,对另一个线程来说并不是立即可见的。
classData{inta =0;intb =0;intx =0;inty =0;// a线程执行publicvoidthreadA(){ a =1; x = b; }// b线程执行publicvoidthreadB(){ b =2; y = a; }}
如果有两个线程同时分别执行了threadA和threadB方法。可能会出现x==y==0这个情况(当然这个情况比较少的出现)。
因为a和b被赋值后,还没有刷新到主内存中,就执行x = b和y = a的语句,这个时候线程并不知道a和b还已经被修改了,依然是原来的值0。
1.3 有序性
为了提高程序执行性能,Java内存模型允许编译器和处理器对指令进行重排序。重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
classReorder{intx =0;booleanflag =false;publicvoidwriter(){ x =1; flag =true; }publicvoidreader(){if(flag) {inta = x * x; … } }}
例如上例中,我们使用flag变量,标志x变量已经被赋值了。但是这两个语句之间没有数据依赖,所以它们可能会被重排序,即flag = true语句会在x = 1语句之前,那么这么更改会不会产生问题呢?
在单线程模式下,不会有任何问题,因为writer方法是一个整体,只有等writer方法执行完毕,其他方法才能执行,所以flag = true语句和x = 1语句顺序改变没有任何影响。
在多线程模式下,就可能会产生问题,因为writer方法还没有执行完毕,reader方法就被另一线程调用了,这个时候如果flag = true语句和x = 1语句顺序改变,就有可能产生flag为true,但是x还没有赋值情况,与程序意图产生不一样,就会产生意想不到的问题。
1.4 原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
x =1;// 原子性y = x;// 不是原子性x = x +1;// 不是原子性x++;// 不是原子性System.out.println(x);// 原子性
公式2:有两个原子性操作,读取x的值,赋值给y。公式3:也是三个原子性操作,读取x的值,加1,赋值给x。公式4:和公式3一样。
所以对于原子性操作就两种:1. 将基本数据类型常量赋值给变量。2. 读取基本数据类型的变量值。任何计算操作都不是原子的。
1.5 小结
多线程操作共享变量,会产生上面三个问题,可见性、有序性和原子性。
可见性: 一个线程改变共享变量,可能并没有立即刷新到主内存,这个时候另一个线程读取共享变量,就是改变之前的值。所以这个共享变量的改变对其他线程并不是可见的。
有序性: 编译器和处理器会对指令进行重排序,语句的顺序发生改变,这样在多线程的情况下,可能出现奇怪的异常。
原子性: 只有对基本数据类型的变量的读取和赋值操作是原子性操作。
要解决这三个问题有两种方式:
volatile关键字:它只能解决两个问题可见性和有序性问题,但是如果volatile修饰基本数据类型变量,而且这个变量只做读取和赋值操作,那么也没有原子性问题了。比如说用它来修饰boolean的变量。
加锁:可以保证同一时间只有同一线程操作共享变量,当前线程操作共享变量时,共享变量不会被别的线程修改,所以可见性、有序性和原子性问题都得到解决。分为synchronized同步锁和JUC框架下的Lock锁。
二. volatile关键字
volatile关键字作用
1.可见性: 对一个volatile变量的读取,总是能看到(任意线程)对这个volatile变量最后的写入。
有序性: 禁止指令重排序,即在程序中在volatile变量进行操作时,在其之前的操作肯定已经全部执行了,而且结果已经对后面的操作可见,在其之后的操作肯定还没有执行。
这个的具体解释,大家请看《深入理解Java内存模型》里面关于happens-before规则的讲解。
classVolatileFeaturesExample{//使用volatile声明一个基本数据类型变量vlvolatilelongvl =0L;//对于单个volatile基本数据类型变量赋值publicvoidset(longl){ vl = l; }//对于单个volatile基本数据类型变量的复合操作publicvoidgetAndIncrement(){ vl++; }//对于单个volatile基本数据类型变量读取publiclongget(){returnvl; }}classVolatileFeaturesExample{//声明一个基本数据类型变量vllongvl =0L;// 相当于加了同步锁publicsynchronizedvoidset(longl){ vl = l; }// 普通方法publicvoidgetAndIncrement(){longtemp = get(); temp +=1L; set(temp); }// 相当于加了同步锁publicsynchronizedlongget(){returnvl; }}
如果volatile修饰基本数据类型变量,而且只对这个变量做读取和赋值操作,那么就相当于加了同步锁。
三. synchronized同步锁
synchronized同步锁作用是访问被锁住的资源时,只要获取锁的线程才能操作被锁住的资源,其他线程必须阻塞等待。
所以一个线程来说,可以阻塞等待,可以运行,那么线程到底有哪些状态呢?
3.1 线程状态
状态转换图
线程分为5种状态:
新建状态(New):创建一个Thread对象,那么该thread对象就是新建状态。
可运行状态(Runnable):表示该thread线程随时都可以运行,只要获取CPU的执行权。
注: 该状态可以由新建状态转换而来(通过调用thread的start方法),也可以由阻塞状态转换而来
运行状态(Running):表示该线程正在运行,注意运行状态只能从可运行状态到达。
阻塞状态(Blocked):表示该线程当前停止运行,主要分为三种情况:
1). 同步阻塞状态:线程获取同步锁失败,就会进入同步阻塞状态。
2). 等待阻塞状态:线程调用wait方法,进入该状态。注:join方法本质也是通过wait方法实现的。
3). 其他阻塞状态:通过Thread.sleep方法让线程睡眠,开启IO流让线程等待阻塞。
死亡状态(Dead):当thread的run方法运行完毕,那么线程就进入死亡状态。该状态不能再转换成其他状态。
3.2 synchronized同步方法或者同步块
synchronized同步方法或者同步块具体是怎样操作的呢?
相当于有一个大房间,房间门上有一把锁lock,房间里面存放的是所有与这把锁lock关联的同步方法或者同步块。
当某一个线程要执行这把锁lock的一个同步方法或者同步块时,它就来到房间门前,如果发现锁lock还在,那么它就拿着锁进入房间,并将房间锁上,它可以执行房间中任何一个同步方法或者同步块。
这时又有另一个线程要执行这把锁lock的一个同步方法或者同步块时,它就来到房间门前,发现锁lock没有了,就只能在门外等待,此时该线程就在synchronized同步阻塞线程池中。
等到拿到锁lock的线程,同步方法或者同步块代码执行完毕,它就会从房间中退出来,将锁放到门上。
这时在门外等待的线程就争夺这把锁lock,拿到锁的线程就可以进入房间,其他线程则又要继续等待。
注:synchronized 锁是锁住所有与这个锁关联的同步方法或者同步块。
synchronized的同步锁到底是什么呢?
其实就是java对象,在Java中,每一个对象都拥有一个锁标记(monitor),也称为监视器,多线程同时访问某个对象时,线程只有获取了该对象的锁才能访问。
3.3 wait与notify、notifyAll
这三个方法主要用于实现线程之间相互等待的问题。
调用对象lock的wait方法,会让当前线程进行等待,即将当前线程放入对象lock的线程等待池中。调用对象lock的notify方法会从线程等待池中随机唤醒一个线程,notifyAll方法会唤醒所有线程。
注:对象lock的wait与notify、notifyAll方法调用必须放在以对象lock为锁的同步方法或者同步块中,否则会抛出IllegalMonitorStateException异常。
wait与notify、notifyAll具体是怎么操作的呢?
前面过程与synchronized中介绍的一样,当调用锁lock的wait方法时,该线程(即当前线程)退出房间,归还锁lock,但并不是进入synchronized同步阻塞线程池中,而是进入锁lock的线程等待池中。
这时另一个线程拿到锁lock进行房间,如果它执行了锁lock的notify方法,那么就会从锁lock的线程等待池中随机唤醒一个线程,将它放入synchronized同步阻塞线程池中(记住只有拿到锁lock的线程才能进行房间)。调用锁lock的notifyAll方法,即唤醒线程等待池所有线程。
注:当被wait阻塞的线程再次进入synchronized同步代码块时,会从wait方法调用之后的地方继续执行。
在锁lock的线程等待池中的线程,只有四种方式唤醒:
通过notify()唤醒
通过notifyAll()唤醒
通过interrupt()中断唤醒
如果是通过调用wait(long timeout)进入等待状态的线程,当时间超时的时候,也会被唤醒。
注意wait、notify和notifyAll方法必须先获取锁才能调用,否则抛出IllegalMonitorStateException异常。而只有synchronized模块才能让当前线程获取锁,所以wait方法只能在synchronized模块中执行。
四. 其他重要方法
4.1 join方法
让当前线程等待另一个线程执行完成后,才继续执行。
publicfinalvoidjoin()throwsInterruptedException {join(0); }publicfinalsynchronizedvoidjoin(longmillis)throwsInterruptedException {// 获取当前系统毫秒数longbase = System.currentTimeMillis();longnow =0;// millis小于0,抛出异常if(millis <0) {thrownewIllegalArgumentException(“timeout value is negative”); }if(millis ==0) {// 通过isAlive判断当前线程是否存活while(isAlive()) {// wait(0)表示当前线程无限等待wait(0); } }else{// 通过isAlive判断当前线程是否存活while(isAlive()) {longdelay = millis – now;if(delay <=0) {break; }// 当前线程等待delay毫秒,超过时间,当前线程就被唤醒wait(delay); now = System.currentTimeMillis() – base; } } }
join方法是Thread中的方法,synchronized方法同步的锁对象就是Thread对象,通过调用Thread对象的wait方法,让当前线程等待
注意:这里是让当前线程等待,即当前调用join方法的线程,而不是Thread对象的线程。那么当前线程什么时候会被唤醒呢?
当Thread对象线程执行完毕,进入死亡状态时,会调用Thread对象的notifyAll方法,来唤醒Thread对象的线程等待池中所有线程。
示例:
publicstaticvoidjoinTest(){ Thread thread =newThread(newRunnable() { @Overridepublicvoidrun(){for(inti =0; i <10; i++) {try{ Thread.sleep(100); }catch(InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+”: i===”+i); } } },”t1″); thread.start();try{ thread.join(); }catch(InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+”: end”); }
4.2 sleep方法
只是让当前线程等待一定的时间,才继续执行。
4.3 yield方法
将当前线程状态从运行状态转成可运行状态,如果再获取CPU执行权,就继续执行。
4.4 interrupt方法
中断线程,它会中断处于阻塞状态下的线程,但是对于运行状态下的线程不起任何作用。
示例:
publicstaticvoidinterruptTest(){// 处于阻塞状态下的线程Thread thread =newThread(newRunnable() { @Overridepublicvoidrun(){try{ System.out.println(Thread.currentThread().getName()+” 开始”); Thread.sleep(1000); System.out.println(Thread.currentThread().getName()+” 结束”); }catch(InterruptedException e) { System.out.println(Thread.currentThread().getName()+” 产生异常”); } } },”t1″); thread.start();// 处于运行状态下的线程Thread thread1 =newThread(newRunnable() { @Overridepublicvoidrun(){ System.out.println(Thread.currentThread().getName()+” 开始”);inti =0;while(i < Integer.MAX_VALUE -10) { i = i +1;for(intj =0; j < i; j++); } System.out.println(Thread.currentThread().getName()+” i==”+i); System.out.println(Thread.currentThread().getName()+” 结束”); } },”t2″); thread1.start();try{ Thread.sleep(10); }catch(InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+” 进行中断”); thread.interrupt(); thread1.interrupt(); }
4.5 isInterrupted方法
返回这个线程是否被中断。注意当调用线程的interrupt方法后,该线程的isInterrupted的方法就会返回true。如果异常被处理了,又会将该标志位置位false,即isInterrupted的方法返回false。
4.6 线程优先级以及守护线程
在java中线程优先级范围是1~10,默认的优先级是5。
在java中线程分为用户线程和守护线程,isDaemon返回是true,表示它是守护线程。当所有的用户线程执行完毕后,java虚拟机就会退出,不管是否还有守护线程未执行完毕。
当创建一个新线程时,这个新线程的优先级等于创建它线程的优先级,且只有当创建它线程是守护线程时,新线程才是守护线程。
当然也可以通过setPriority方法修改线程的优先级,已经setDaemon方法设置线程是否为守护线程。
五. 实例讲解
5.1 不加任何同步锁
importjava.util.Collections;importjava.util.List;importjava.util.concurrent.CopyOnWriteArrayList;importjava.util.concurrent.CountDownLatch;classData {intnum;publicData(intnum){this.num = num; }publicintgetAndDecrement(){returnnum–; }}classMyRun implements Runnable {privateData data;// 用来记录所有卖出票的编号privateListlist;privateCountDownLatch latch;publicMyRun(Data data, Listlist, CountDownLatch latch){this.data = data;this.list=list;this.latch = latch; } @Overridepublicvoidrun(){try{ action(); } finally {// 释放latch共享锁latch.countDown(); } }// 进行买票操作,注意这里没有使用data.num>0作为判断条件,直到卖完线程退出。// 那么做会导致这两处使用了共享变量data.num,那么做多线程同步时,就要考虑更多条件。// 这里只for循环了5次,表示每个线程只卖5张票,并将所有卖出去编号存入list集合中。publicvoidaction(){for(inti =0; i <5; i++) {try{ Thread.sleep(10); }catch(InterruptedException e) { e.printStackTrace(); }intnewNum = data.getAndDecrement(); System.out.println(“线程”+Thread.currentThread().getName()+” num==”+newNum);list.add(newNum); } }}publicclassThreadTest {publicstaticvoidstartThread(Data data, String name, Listlist,CountDownLatch latch){ Thread t =newThread(newMyRun(data,list, latch), name); t.start(); }publicstaticvoidmain(String[] args){// 使用CountDownLatch来让主线程等待子线程都执行完毕时,才结束CountDownLatch latch =newCountDownLatch(6);longstart = System.currentTimeMillis();// 这里用并发list集合Listlist=newCopyOnWriteArrayList(); Data data =newData(30); startThread(data,”t1″,list, latch); startThread(data,”t2″,list, latch); startThread(data,”t3″,list, latch); startThread(data,”t4″,list, latch); startThread(data,”t5″,list, latch); startThread(data,”t6″,list, latch);try{ latch.await(); }catch(InterruptedException e) { e.printStackTrace(); }// 处理一下list集合,进行排序和翻转Collections.sort(list); Collections.reverse(list); System.out.println(list);longtime = System.currentTimeMillis() – start;// 输出一共花费的时间System.out.println(“/n主线程结束 time==”+time); }}
输出的结果是
线程t2num==29线程t6num==27线程t5num==28线程t4num==28线程t1num==30线程t3num==30线程t2num==26线程t4num==24线程t6num==25线程t5num==23线程t1num==22线程t3num==21线程t4num==20线程t6num==19线程t5num==18线程t2num==17线程t1num==16线程t3num==15线程t4num==14线程t5num==12线程t6num==13线程t1num==9线程t3num==10线程t2num==11线程t1num==8线程t6num==5线程t2num==7线程t5num==3线程t3num==4线程t4num==6[30,30,29,28,28,27,26,25,24,23,22,21,20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3]主线程结束 time==62
从结果中发现问题,出现了重复票,所以30张票没有被卖完。最主要的原因就是Data类的getAndDecrement方法操作不是多线程安全的。
首先它不能保证原子性,分为三个操作,先读取num的值,然后num自减,在返回自减前的值。
因为num不是volatile关键字修饰的,它也不能保证可见性和有序性。
所以只要保证getAndDecrement方法多线程安全,那么就可以解决上面出现的问题。那么保证getAndDecrement方法多线程安全呢?最简单的方式就是在getAndDecrement方法前加synchronized关键字。
这是synchronized关键锁就是这个data对象实例,所以保证了多线程调用getAndDecrement方法时,只有一个线程能调用,等待调用完成,其他线程才能调用getAndDecrement方法。
因为同一时间只有一个线程调用getAndDecrement方法,所以它在做num–操作时,不用担心num变量会发生改变。所以原子性、可见性和有序性都可以得到保证。
5.2 使用最小同步锁
classData{intnum; public Data(intnum) {this.num=num; }// 将getAndDecrement方法加了同步锁public synchronizedintgetAndDecrement() {returnnum–; }}
输出结果
线程t1num==30线程t2num==29线程t6num==28线程t4num==26线程t3num==27线程t5num==25线程t6num==22线程t2num==21线程t3num==23线程t1num==24线程t4num==20线程t5num==19线程t2num==18线程t3num==17线程t5num==13线程t4num==14线程t6num==16线程t1num==15线程t2num==12线程t4num==9线程t1num==7线程t5num==10线程t3num==11线程t6num==8线程t4num==6线程t2num==3线程t1num==2线程t3num==4线程t5num==5线程t6num==1[30,29,28,27,26,25,24,23,22,21,20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2,1]主线程结束 time==61
我们只是将Data的getAndDecrement方法加了同步锁,发现解决了多线程并发问题。主要是因为我们只在一处使用了共享变量num,所以只需要将这处加同步就行了。而且你会发现最后花费的总时间与没加同步锁时几乎一样,那么因为我们同步代码足够小。
相反地,我们加地同步锁不合理,可能也能实现多线程安全,但是耗时就会大大增加。
5.3 不合理地使用同步锁
@Overridepublicvoidrun(){try{synchronized(data){ action(); } }finally{// 释放latch共享锁latch.countDown(); } }
输入结果:
线程t1num==30线程t1num==29线程t1num==28线程t1num==27线程t1num==26线程t6num==25线程t6num==24线程t6num==23线程t6num==22线程t6num==21线程t5num==20线程t5num==19线程t5num==18线程t5num==17线程t5num==16线程t4num==15线程t4num==14线程t4num==13线程t4num==12线程t4num==11线程t3num==10线程t3num==9线程t3num==8线程t3num==7线程t3num==6线程t2num==5线程t2num==4线程t2num==3线程t2num==2线程t2num==1[30,29,28,27,26,25,24,23,22,21,20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2,1]主线程结束 time==342
在这里我们将整个action方法,放入同步代码块中,也可以解决多线程冲突问题,但是所耗费的时间是在getAndDecrement方法上加同步锁时间的几倍。
所以我们在加同步锁的时候,那些需要同步,就是看那些地方使用了共享变量。比如这里只在getAndDecrement方法中使用了同步变量,所以只要给它加锁就行了。
但是如果在action方法中,使用data.num>0来作为循环条件,那么在加同步锁时,就必须将整个action方法放在同步模块中,因为我们必须保证,在data.num>0判断到getAndDecrement方法调用这些代码都是在同步模块中,不然就会产生多线程冲突问题。
福利:
想要了解更多多线程知识点的,可以关注我一下,我后续也会整理更多关于多线程这一块的知识点分享出来,另外顺便给大家推荐一个交流学习群:488694198,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、多线程、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多。
原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/tech/pnotes/7645.html