美文网首页IT面试程序员我爱编程
干货:Java多线程详解(内附源码)

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

作者: 程序员技术圈 | 来源:发表于2018-04-06 22:58 被阅读233次

    线程是程序执行的最小单元,多线程是指程序同一时间可以有多个执行单元运行(这个与你的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性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多。

    相关文章

      网友评论

      本文标题:干货:Java多线程详解(内附源码)

      本文链接:https://www.haomeiwen.com/subject/twcehftx.html