干货:Java多线程详解(内附源码)

线程是程序执行的最小单元,多线程是指程序同一时间可以有多个执行单元运行(这个与你的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方法调用这些代码都是在同步模块中,不然就会产生多线程冲突问题。

福利:

想要了解更多多线程知识点的,可以关注我一下,我后续也会整理更多关于多线程这一块的知识点分享出来,另外顺便给大家推荐一个交流学习群:650385180,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、多线程、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏python3

习题31:访问列表元素

访问列表中的元素,使用下标的方式,通常以0开始(为什么是0而不是1),这里程序的设计就是如此,个人觉得没有必要纠结,如有兴趣,可自行查看资料

8920
来自专栏Java架构沉思录

聊聊Java动态代理(下)

前言 在之前的文章《聊聊Java动态代理(上)》中,笔者为大家介绍了Java原生的动态代理,并指出Java原生的动态代理有一个缺点就是被代理类必须显示地实现某个...

31290
来自专栏奔跑的蛙牛技术博客

Java并发知识点(1)

对线程调用interrupt方法,线程中断状态将被置位(线程总会不断的检验这个标志,判断线程是否被中断),想要知道线程是否被置位,就要调用静态的方法

11340
来自专栏Java帮帮-微信公众号-技术文章全总结

03.线程安全/同步/线程通讯

03.线程安全/同步/线程通讯 一.一个典型的Java线程安全例子 ? ? 上面例子很容易理解,有一张银行卡,里面有1000的余额,程序模拟你和你老婆同时在取款...

35370
来自专栏Python小屋

使用Python检查密码安全程度

本文主要演示几种内置用法的用法和代码优化技巧,所以没有使用正则表达式。 import string def check(pwd): #密码必须至少包含6个字符...

35250
来自专栏linux运维学习

linux学习第六十六篇:shell中的函数,shell中的数组,告警系统需求分析

shell中的函数 函数就是把一段代码整理到了一个小单元中,并给这个小单元起一个名字,当用到这段代码时直接调用这个小单元的名字即可。 格式: function ...

31080
来自专栏北京马哥教育

Linux Shell脚本面试25问

Q:1 Shell脚本是什么、它是必需的吗? 答:一个Shell脚本是一个文本文件,包含一个或多个命令。作为系统管理员,我们经常需要使用多个命令来完成一项任务...

38650
来自专栏C/C++基础

模板与分离编译模式

一个程序(项目)由若干个源文件共同实现,而每个源文件单独编译生成目标文件,最后将所有目标文件连接起来形成单一的可执行文件的过程成为分离编译模式。

10320
来自专栏PPV课数据科学社区

python多线程编程(2): 线程的创建、启动、挂起和退出

如上一节,python 的threading.Thread类有一个run方法,用于定义线程的功能函数,可以在自己的线程类中覆盖该方法。而创建自己的线程实例后,通...

25860
来自专栏java一日一条

从根源上解析 Java volatile 关键字的实现

也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。

5920

扫码关注云+社区

领取腾讯云代金券