Java多线程 - Go语言中文社区

Java多线程


进程和线程

进程

所有运行中的任务通常对应一个进程,当一个程序进入内存运行时,即变成一个进程.进程是处于运行过程中的程序,并且具有一定独立的功能,进程是系统进行资源分配和调度的一个独立单位.

进程的特性

独立性
动态性
并发性:多个进程可以在单个处理器上并发执行,多个进程之间不会相互影响.

并发和并行的区别

并行(parellel)指的是在同一时刻,有多条指令在多个处理器上同时被执行;
并发指的是在同一时刻只能有一条指令执行,但多个进程指令被快速轮换执行,使得宏观上具有多个进程同时执行的结果.

多线程

多线程扩展了多进程的概念,使得同一进程可以同时并发处理多个任务.线程也被称为轻量级进程,线程时进程的执行单元.线程在程序中是独立的并发的执行流.当进程被初始化之后,主线程就被创建了.

线程是进程的组成部分,一个进程可以有多个线程,但一个线程必须有一个父进程.线程可以拥有自己的栈,自己的程序计数器和自己的局部变量,但不拥有系统资源,它与父进程的其他线程共享该进程所拥有的全部资源.因为多个线程共享父进程的所有资源,因此编程比较方便,但必须更加小心,需要确保线程不会妨碍到同一进程里的其他线程.

线程是独立运行的,它并不知道进程中是否还有其他的线程存在.线程的执行是抢占式的:当前运行的线程在任何时候都可能被挂起,以便另一个线程可以运行.

一个线程可以创建和撤销另一个线程,同一个进程中的多个线程之间可以并发执行.

从逻辑角度来看,多线程存在于一个应用程序中,让一个应用程序可以有多个执行部分同时进行,但操作系统无须将多个线程看做多个独立的应用,对多线程实现调度和管理以及资源分配.线程的调度和管理由进程本身负责完成.

总结:

  • 1.操作系统可以同时执行多个任务,每个任务就是进程;

  • 2.进程可以同时执行多个任务,每个任务就是线程.

多线程的优势

  • 1.进程之间不能贡献内存,但线程之间贡献内存很容易
  • 2.系统创建进程需要为该进程重新分配系统资源,但创建线程则代价小得多,因此使用多线程来实现多任务并发比多进程的效率高.
  • 3.Java语言内置了多线程功能支持,而不是单纯地作为底层操作系统的调度方式,从而简化了Java的多线程编程.

多线程的应用是很广泛的,比如一个浏览器必须能同时下载多个图片,一个web服务器必须能同时响应多个用户请求;Java虚拟机本身就在后台提供了一个超级线程来进行垃圾回收.....

线程的创建和启动

Java使用Thread类代表线程,每个线程对象都必须是Thread类或其子类的实例.每个线程的作用是完成一定的任务,实际上是执行一段程序流.

继承Thread类创建线程类

  • 1.定义Thread类的子类,并重写该类的run()方法,该run()方法的方法体就代表了线程需要完成的任务.因此把run()方法称为线程执行体
  • 2.创建Thread子类的实例,即创建了线程对象
  • 3.调用线程对象的start()方法来启动该线程.
// 通过继承Thread类来创建线程类
public class FirstThread extends Thread
{
    private int i ;
    // 重写run方法,run方法的方法体就是线程执行体
    public void run()
    {
        for ( ; i < 100 ; i++ )
        {
            // 当线程类继承Thread类时,直接使用this即可获取当前线程
            // Thread对象的getName()返回当前该线程的名字
            // 因此可以直接调用getName()方法返回当前线程的名
            System.out.println(getName() +  " " + i);
        }
    }
    public static void main(String[] args)
    {
        for (int i = 0; i < 100;  i++)
        {
            // 调用Thread的currentThread方法获取当前线程
            System.out.println(Thread.currentThread().getName()
                +  " " + i);
            if (i == 20)
            {
                // 创建、并启动第一条线程
                new FirstThread().start();
                // 创建、并启动第二条线程
                new FirstThread().start();
            }
        }
    }
}

Java程序运行时默认的主线程,main()方法的方法体就是主线程的线程执行体.

可以看到Thread-0Thread-1两个线程的输出的i变量不连续-----注意:i变量是FirstThread的实例变量,而不是局部变量,但是因为程序每次创建线程对象都需要创建一个FirstThread对象,所以Thread-0Thread-1不能共享该实例变量.

使用继承Thread类的方法来创建线程类时,多个线程之间是无法共享线程类的实例变量.

实现Runnable接口创建线程类

  • 1.定义Runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体.
  • 2.创建Runnable实现类的实例,并以此实例作为Threadtarget来创建Thread对象,该Thread对象才是真正的线程对象
  • 3.调用线程对象的start()方法来启动该线程
// 通过实现Runnable接口来创建线程类
public class SecondThread implements Runnable
{
    private int i ;
    // run方法同样是线程执行体
    public void run()
    {
        for ( ; i < 100 ; i++ )
        {
            // 当线程类实现Runnable接口时,
            // 如果想获取当前线程,只能用Thread.currentThread()方法。
            System.out.println(Thread.currentThread().getName()
                + "  " + i);
        }
    }

    public static void main(String[] args)
    {
        for (int i = 0; i < 100;  i++)
        {
            System.out.println(Thread.currentThread().getName()
                + "  " + i);
            if (i == 20)
            {
                SecondThread st = new SecondThread();     // ①
                //通过创建Runnable实现类的对象SecondThread ,
                //以Runnable实现类的对象SecondThread 作为Thread的target来创建Thread对象
                // 通过new Thread(target , name)方法创建新线程
                new Thread(st , "新线程1").start();
                new Thread(st , "新线程2").start();
            }
        }
    }
}

当线程类实现Runnable接口时,如果想获取当前线程,只能用Thread.currentThread()方法

可以看到两个子线程的i变量是连续的这是因为采用Runnable接口的方式创建的多个线程可以共享线程类的实例变量.是因为:程序创建的Runnable对象只是线程的target,而多个线程可以共享一个target,所以多个线程可以共享一个线程类(实际上应该是线程的target类)的实例变量.

使用Callable和Future创建线程

通过实现Runnable接口创建多线程时,Thread类的作用就是把run()方法包装成线程执行体.从Java5开始,Java提供了Callable接口,该接口可以理解为是Runnable接口的增强版,Callable接口提供了一个call()方法可以作为线程执行体,但call()方法比run()方法功能更强大,call()方法可以有返回值.call()方法可以声明抛出的异常.
但是Callable接口并不是Runnable接口的子接口,所以Callable对象不能直接作为Threadtarget.而且call()方法还有一个返回值,call()方法并不是直接调用的,它是作为线程执行体被调用的.好在Java提供了Future接口来代表Callable接口里的Call()方法的返回值,并为Future接口提供了一个FutureTask实现类,该实现类既实现了Future接口,并实现了Runnable接口----可以作为Thread类的target.
在Future接口里定义了几个公共方法来控制它关联的Callable任务.

Callable接口有泛型限制,并且Callable接口里的泛型形参类型与call()方法返回值类型相同.而且Callable接口是函数式接口,可以用Lambda表达式创建Callable对象

创建并启动具有返回值的线程的步骤如下:

  • 1.创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,且该call()方法有返回值,再创建Callable实现类的实例.
  • 2.使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值
  • 3.使用FutureTask对象作为Thread对象的target创建并启动新线程
  • 4.调用FutureTask对象的get()方法来获得子线程执行结束后的返回值.
public class ThirdThread
{
    public static void main(String[] args)
    {
        // 创建Callable对象
        ThirdThread rt = new ThirdThread();
        // 先使用Lambda表达式创建Callable<Integer>对象
        // 使用FutureTask来包装Callable对象
        FutureTask<Integer> task = new FutureTask<Integer>((Callable<Integer>)() -> {
            int i = 0;
            for ( ; i < 100 ; i++ )
            {
                System.out.println(Thread.currentThread().getName()
                    + " 的循环变量i的值:" + i);
            }
            // call()方法可以有返回值
            return i;
        });
        for (int i = 0 ; i < 100 ; i++)
        {
            System.out.println(Thread.currentThread().getName()
                + " 的循环变量i的值:" + i);
            if (i == 20)
            {
                // 实质还是以Callable对象来创建、并启动线程
                new Thread(task , "有返回值的线程").start();
            }
        }
        try
        {
            // 获取线程返回值
            System.out.println("子线程的返回值:" + task.get());
        }
        catch (Exception ex)
        {
            ex.printStackTrace();
        }
    }
}

创建线程的三种方式对比

采用实现Runnable,Callable接口的方式创建多线程的优缺点:

  • 1.线程类只是实现了Runnable接口或Callable接口,还可以继承其他类
  • 2.多个线程可以共享同一个target对象,非常适合多个相同线程来处理同一份资源的情况,较好的体现了面向对象的思想
  • 3.需要访问当前线程,则必须使用Thread.currentThread()方法

采用继承Thread类的方式创建多线程的优缺点:

  • 1.因为该线程已经继承了Thread类,所以不能在继承其他父类
  • 2.编写简单,如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程.

线程的生命周期

线程的生命周期中,需要经历新建(New),就绪(Runnable),运行(Running),堵塞(Blocked),死亡(Dead)5种状态.

新建和就绪状态

当程序new关键字创建了一个线程之后,该线程就处于新建状态.此时它和其他java对象一样,仅仅由java虚拟机为其分配内存,并初始化其成员变量的值,此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体了.

线程对象开始执行start()方法之后,该线程就处于就绪状态,Java虚拟机会为其创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了.

注意:启动线程使用的是start()方法,而不是run()方法!永远都不要调用线程对象的run()方法!!!调用start()方法来启动线程,系统会把该run()方法当成线程来处理;如果直接调用线程对象的run()方法,系统会把线程对象当做普通对象来处理,而run()方法也是一个普通方法,而不是线程执行体.

public class InvokeRun extends Thread
{
    private int i ;
    // 重写run方法,run方法的方法体就是线程执行体
    public void run()
    {
        for ( ; i < 100 ; i++ )
        {
            // 直接调用run方法时,Thread的this.getName返回的是该对象名字,
            // 而不是当前线程的名字。
            // 使用Thread.currentThread().getName()总是获取当前线程名字
            System.out.println(Thread.currentThread().getName()
                +  " " + i);   // ①
        }
    }
    public static void main(String[] args)
    {
        for (int i = 0; i < 100;  i++)
        {
            // 调用Thread的currentThread方法获取当前线程
            System.out.println(Thread.currentThread().getName()
                +  " " + i);
            if (i == 20)
            {
                // 直接调用线程对象的run方法,
                // 系统会把线程对象当成普通对象,run方法当成普通方法,
                // 所以下面两行代码并不会启动两条线程,而是依次执行两个run方法
                new InvokeRun().run();
                new InvokeRun().run();
            }
        }
    }
}

结果如下(截取部分):

image.png

如果直接调用线程对象的run()方法,则run()方法不能直接通过getName()方法来获取到当前线程的名字,而是需要使用Thread.currentThread()方法先获得当前线程,再调用线程对象的getName()方法来获取线程的名字.

不难看出,启动线程的正确方法是调用Thread对象的start()方法,而不是直接调用run()方法,否则就变成单线程程序了.

需要指出的是:当调用了线程的run()方法之后,该线程便不再处于新建状态,不要再次调用线程对象的start()方法.
只能对处于新建状态的线程调用start()方法,否则将引发IllegalThreadStateException异常

调用线程对象的start()方法之后,该线程立即进入就绪状态-------就绪状态相当于"等待执行",但该线程并未真正进入运行状态.
比如之前我们演示的secondThread那个程序:并不是到20就马上开启一个新线程的.

如果希望调用子线程的start()方法后子线程立即开始执行,程序可以使用Thread.sleep(1)来让当前运行的线程(主线程)睡眠1毫秒----1毫秒就够了

运行和堵塞状态

如果处于就绪状态的线程获得了CPU,开始执行run()方法的线程执行体,则该线程处于运行状态.如果计算机只有一个CPU,那么任何时刻都只有一个线程处于运行状态,如果一个多处理器的机器上,将会有多个线程并行(parallel)执行;当线程数大于处理器数时,依然会存在多个线程在同一个CPU上轮换的现象.

抢占式调度和协作式调度策略

抢占式调度:线代桌面和服务器操作系统一般采取抢占式调度策略,系统会给每个可执行的线程一个小时间段来处理任务;当该时间段用完后,系统就会剥夺该线程所占用的资源,让其他线程获得执行的机会.在选择下一个进程的时候,系统会考虑线程的优先级

协作式调度:小型设备如手机则采取协作式调度策略,只有当一个线程调用了它的sleep()yeid()方法后才会放弃所占用的资源-----即必须由该线程主动放弃所占用的资源

线程将会进入堵塞状态

  • 1.线程调用sleep()方法主动放弃所占用的处理器资源
  • 2.线程调用了一个堵塞式IO方法,在该方法返回之前,该线程被堵塞
  • 3.线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有
  • 4.线程在等待某个通知(notify)
  • 5.线程调用了线程的suspend()方法将该线程挂起,这个方法容易引起死锁(要尽量避免!!!)
    如果当前线程被堵塞之后,其他线程就可以获得执行的机会,被堵塞的线程会在合适的时候重新进入就绪状态,注意是就绪状态而不是运行状态.被堵塞线程的堵塞解除后,必须重新等待线程调度器再次调用它.

解除上面的堵塞

  • 1.调用sleep()方法的线程经过了指定的时间
  • 2.线程调用的堵塞式IO方法已经返回
  • 3.线程成功地获得了试图取得的同步监视器
  • 4.线程正在等待某个通知时,其他线程发出了一条通知
  • 5.处于挂起状态的线程被调用了resume()恢复方法
    线程状态转换图

    不难看出,线程从堵塞状态进入就绪状态,无法直接进入运行状态.而就绪和运行状态之间的转换通常不受程序控制,而是由系统线程调度所决定,当处于就绪状态的线程获取到CPU的资源时,该线程进入运行状态;当处于运行状态的线程失去处理器资源时,该线程进入就绪状态. 有一个方法例外:可以调用yield()方法可以让运行状态的线程转入就绪状态.

线程死亡

线程会以下列三种方式结束,结束后就处于死亡状态

  • 1.run()call()方法执行完成,线程正常结束
  • 2.线程抛出一个未捕获的ExceptionError
  • 3.直接调用该线程的stop()方法来结束该线程-----该方法容易引起死锁(不推介!!!)
    当主线程结束时,其他线程不受任何影响,并不会随之结束.一旦子线程启动起来,它就拥有和主线程相同的地位,它不会受主线程的影响

为了测试某个线程是否已经死亡,可以调用该对象的isAlive()方法,当线程处于就绪,运行,堵塞三种状态时,该方法返回true,当线程处于新建死亡两种状态时,该方法将返回false

不要对一个已经死亡的线程再调用start()方法来让它重新启动,死亡就是死亡,该线程将不可再次作为线程执行.如果依然对一个已经死亡的线程再次调用start()方法来启动该线程,将会引发IllegalThreadStateException异常,这表明处于死亡状态的线程已经无法再次运行了.
如下程序可以说明上述现象:

public class StartDead extends Thread
{
    private int i ;
    // 重写run方法,run方法的方法体就是线程执行体
    public void run()
    {
        for ( ; i < 100 ; i++ )
        {
            System.out.println(getName() +  " " + i);
        }
    }
    public static void main(String[] args)
    {
        // 创建线程对象
        StartDead sd = new StartDead();
        for (int i = 0; i < 300;  i++)
        {
            // 调用Thread的currentThread方法获取当前线程
            System.out.println(Thread.currentThread().getName()
                +  " " + i);
            if (i == 20)
            {
                // 启动线程
                sd.start();
                // 判断启动后线程的isAlive()值,输出true
                System.out.println(sd.isAlive());
            }
            // 只有当线程处于新建、死亡两种状态时isAlive()方法返回false。
            // 当i > 20,则该线程肯定已经启动过了,如果sd.isAlive()为假时,
            // 那只能是死亡状态了。
            if (i > 20 && !sd.isAlive())

            {
                // 试图再次启动该线程
                sd.start();
            }
        }
    }
}

总结:不要对一个已经死亡的线程再调用start()方法,程序只能对新建状态的线程调用start()方法,对新建状态的线程两次调用start()方法也是错误的,上述两种情况都会引发IllegalThreadStateException异常

控制线程

join线程

Thread提供了让一个线程等待另一个线程完成的方法-------join()方法.当某个执行流中调用其他线程的join()方法时,调用线程将被堵塞,直到被join()方法加入的join线程执行完为止.

比如下面程序中的mian线程即主线程,主线程中调用了其他线程(jt线程)的join()方法,此时调用线程(main线程)将被堵塞,直到被join()方法加入的join线程执行完毕为止.

join()方法通常由使用线程的程序调用,目的是:将大问题划分为许多小问题,每个小问题分配一个线程.当所有的小问题都得到解决处理后,再调用主线程来进一步操作.

public class JoinThread extends Thread
{
    // 提供一个有参数的构造器,用于设置该线程的名字
    public JoinThread(String name)
    {
        super(name);
    }
    // 重写run()方法,定义线程执行体
    public void run()
    {
        for (int i = 0; i < 100 ; i++ )
        {
            System.out.println(getName() + "  " + i);
        }
    }
    public static void main(String[] args)throws Exception
    {
        // 启动子线程
        new JoinThread("新线程").start();
        for (int i = 0; i < 100 ; i++ )
        {
            if (i == 20)
            {
                JoinThread jt = new JoinThread("被Join的线程");
                jt.start();
                // main线程调用了jt线程的join()方法,main线程
                // 必须等jt执行结束才会向下执行
                jt.join();
            }
            System.out.println(Thread.currentThread().getName()
                + "  " + i);
        }
    }
}

image.png

上述代码中main线程中调用了jt线程的join()方法,main线程必须要等jt线程执行完毕之后才会向下执行

join()方法有如下三种重载的方法:

  • 1.join():等待被join的线程执行完成.
  • 2.join(long millis):等待被join的线程的时间最长为millis毫秒.
  • 3.join(long millis,int nanos):等待被join的线程的事件最长为millis毫秒加nanos毫微秒(这个方法很少用!!!)

后台线程

有一种线程,它是在后台运行的,它的任务是为其他的线程提供服务的,这种线程称为后台线程(Daemon Thread).JVM的垃圾回收线程就是典型的后台线程.

后台线程的特征:如果所有的前台线程都死亡,后台线程自动死亡.

调用Thread对象的setDaemon(true)方法可将指定线程设置成后台线程.

public class DaemonThread extends Thread
{
    // 定义后台线程的线程执行体与普通线程没有任何区别
    public void run()
    {
        for (int i = 0; i < 1000 ; i++ )
        {
            System.out.println(getName() + "  " + i);
        }
    }
    public static void main(String[] args)
    {
        DaemonThread t = new DaemonThread();
        // 将此线程设置成后台线程
        t.setDaemon(true);
        // 启动后台线程
        t.start();
        for (int i = 0 ; i < 10 ; i++ )
        {
            System.out.println(Thread.currentThread().getName()
                + "  " + i);
        }
        // -----程序执行到此处,前台线程(main线程)结束------
        // 后台线程也应该随之结束
    }
}


本来该线程应该执行到i=999才会结束,但运行程序时不难发现该后台线程无法运行到999,因为当主线程也就是程序中唯一的前台线程运行结束后,JVM会主动退出,因而后台线程也就被结束了.

Thread类还提供了一个isDaemon()方法来判断当前线程是否为后台线程.

上面程序中:主线程默认是前台线程,t线程默认是后台线程.并不是所有的线程默认都是前台线程,有些线程默认就是后台线程-----------前台线程创建的子线程默认是前台线程,后台线程创建的子线程默认是后台线程.

前台线程死亡之后,JVM会通知后台线程死亡,但从它接收到指令到做出相应,需要一定时间(这也是为什么上图中:在main线程死亡之后Thread-0还进行了一会才死亡的原因).而且将某个线程设置为后台线程,必须要在该线程启动之前设置,即setDaemon(true)必须在start()方法之前调用,否则会引发IllegalThreadStateException异常

线程睡眠sleep

如果需要让当前正在执行的线程暂停一段时间,并进入堵塞状态,则可以通过调用Thread类的静态sleep()方法来实现.

sleep()方法有两种重载形式:

  • 1.static void sleep(long millis):让当前正在执行的线程暂停millis毫秒,并进入堵塞状态
  • 2.static void sleep(long millis,intnanos):让当前正在执行的线程暂停millis毫秒加nanos毫微秒,并进入堵塞状态(很少用)

当前线程调用sleep()方法进入堵塞状态后,在其睡眠时间段内,该线程不会获得执行的机会,即使系统中没有其他可执行的线程,处于sleep()中的线程也不会执行,因此sleep()方法常用来暂停程序的执行.

public class SleepTest
{
    public static void main(String[] args)
        throws Exception
    {
        for (int i = 0; i < 10 ; i++ )
        {
            System.out.println("当前时间: " + new Date());
            // 调用sleep方法让当前线程暂停1s。
            Thread.sleep(1000);
        }
    }
}

程序依次输出10条字符串,输出2条字符串之间的时间间隔为1秒

线程让步:yeid

yeid()方法也是Thread类提供的一个静态方法,它也可以让当前正在执行的线程暂停,但它不会阻塞该线程,它只是让该线程转入就绪状态.yield()只是让当前线程暂停一下,让系统的线程调度器重新调度一次.完全可能的情况是:当某个线程调用了yield()方法暂停之后,线程调度器又将其调度出来重新执行.

当某个线程调用了yield()方法暂停之后,只有优先级与当前线程相同,或者优先级比当前线程更高处于就绪状态的线程才会获得执行的机会.

public class YieldTest extends Thread
{
    public YieldTest(String name)
    {
        super(name);
    }
    // 定义run方法作为线程执行体
    public void run()
    {
        for (int i = 0; i < 50 ; i++ )
        {
            System.out.println(getName() + "  " + i);
            // 当i等于20时,使用yield方法让当前线程让步
            if (i == 20)
            {
                Thread.yield();
            }
        }
    }
    public static void main(String[] args)throws Exception
    {
        // 启动两条并发线程
        YieldTest yt1 = new YieldTest("高级");
        // 将ty1线程设置成最高优先级
        yt1.setPriority(Thread.MAX_PRIORITY);
        yt1.start();
        YieldTest yt2 = new YieldTest("低级");
        // 将yt2线程设置成最低优先级
        yt2.setPriority(Thread.MIN_PRIORITY);
        yt2.start();
    }
}

如果使用多CPU来运行上述程序,可能效果不是很明显因为并发在多核CPU上效果不明显单核CPU比较明显

sleep()yield()方法的区别

  • 1.sleep()方法暂停当前线程后,会给其他线程机会,不会理会其他线程的优先级:但yield()方法只会给优先级相同,或优先级更高的线程执行机会
  • 2.sleep()方法会使线程进入堵塞状态,知道经过堵塞时间才会转入就绪状态;而yield()不会将线程转入堵塞状态,它只是强调当前线程进入就绪状态.因此完全有可能某个线程调用yield()方法暂停之后,立即重新获得处理器资源而被执行
  • 3.sleep()方法声明抛出了InterruptedException异常,所以调用sleep()方法时要么捕捉该异常,要么显式声明抛出该异常;而yield()方法则没有声明抛出任何异常
  • 4.sleep()方法比yield()方法有更好的移植性,通常不建议用yield()方法来控制并发线程的执行.

改变线程的优先级

每个线程执行都有一定的优先级,优先级越高的线程将获得较多的执行机会,而优先级低的线程则获得较少的机会.每个线程默认的优先级都与创建它的父类线程的优先级相同,main线程具有普通优先级,由main线程创建的子线程的优先级也具有普通优先级.

Thread类提供了setPriority(int newPriority),getPriority()方法来设置和返回指定的线程的优先级,setPriority()方法的参数可以是一个整数,范围是1~10之间,也可以使用如下三个静态常量:
MAX_PRIORITY:其值是10
MIN_PRIORITY:其值是1
NORM_PRIORITY:其值是5

public class PriorityTest extends Thread
{
    // 定义一个有参数的构造器,用于创建线程时指定name
    public PriorityTest(String name)
    {
        super(name);
    }
    public void run()
    {
        for (int i = 0 ; i < 50 ; i++ )
        {
            System.out.println(getName() +  ",其优先级是:"
                + getPriority() + ",循环变量的值为:" + i);
        }
    }
    public static void main(String[] args)
    {
        // 改变主线程的优先级
        Thread.currentThread().setPriority(6);
        for (int i = 0 ; i < 30 ; i++ )
        {
            if (i == 10)
            {
                PriorityTest low  = new PriorityTest("低级");
                low.start();
                System.out.println("创建之初的优先级:"
                    + low.getPriority());
                // 设置该线程为最低优先级
                low.setPriority(Thread.MIN_PRIORITY);
            }
            if (i == 20)
            {
                PriorityTest high = new PriorityTest("高级");
                high.start();
                System.out.println("创建之初的优先级:"
                    + high.getPriority());
                // 设置该线程为最高优先级
                high.setPriority(Thread.MAX_PRIORITY);
            }
        }
    }
}

遗憾的是Java虽然提供了10个优先级,但这10个优先级并不都与操作系统兼容,比如win2000只提供了7个优先级所以尽量避免直接为线程指定优先级,而应该采用MAX_PRIORITY,MIN_PRIORITY,NORM_PRIORITY三个静态常量来设置优先级,这样才能保证程序具有良好的可移植性.

线程同步

由系统的线程调度具有一定的随机性造成的,不过即使程序偶然出现问题,那也是由于编程不当引起的.当多个线程来访问同一个数据时,很容易"偶然"出现安全性问题.

线程安全问题:

银行取钱问题:
因为线程调度具有不确定性,假设系统线程调度器在粗体字代码处暂停,让另一个线程执行------为了强制暂停,只要取消上面程序中的粗体字代码的注释即可.

同步代码块:

因为run()方法的方法体不具有同步安全性------程序中有两个并发线程在修改Account对象;而且系统恰好在粗体字代码处执行线程切换,切换给另一个修改Account对象的线程,所以就出现了问题.就跟以前讲的文件并发访问,当有两个进程并发修改同一个文件时就有可能造成异常.

为了解决上述问题,Java引入了同步监视器来解决这个问题,使用同步监视器的通用方法就是同步代码块

//synchronized后括号里的obj就是同步监视器
synchronized(obj)
{
 ......
 //此处的代码就是同步代码块
}

上述代码的含义是:在线程开始执行同步代码块之前,必须先获得对同步监视器的锁定.
任何时刻只能有一个线程可以获得同步监视器的锁定,当同步代码块执行完成之后,该线程会释放该同步监视器的锁定.
同步监视器的目的:阻止两个线程对同一个共享资源进行并发访问,推介使用可能被并发访问的共享资源充当同步监视器

public class DrawThread extends Thread
{
    // 模拟用户账户
    private Account account;
    // 当前取钱线程所希望取的钱数
    private double drawAmount;
    public DrawThread(String name , Account account
        , double drawAmount)
    {
        super(name);
        this.account = account;
        this.drawAmount = drawAmount;
    }
    // 当多条线程修改同一个共享数据时,将涉及数据安全问题。
    public void run()
    {
        // 使用account作为同步监视器,任何线程进入下面同步代码块之前,
        // 必须先获得对account账户的锁定——其他线程无法获得锁,也就无法修改它
        // 这种做法符合:“加锁 → 修改 → 释放锁”的逻辑
        synchronized (account)
        {
            // 账户余额大于取钱数目
            if (account.getBalance() >= drawAmount)
            {
                // 吐出钞票
                System.out.println(getName()
                    + "取钱成功!吐出钞票:" + drawAmount);
                try
                {
                    Thread.sleep(1);
                }
                catch (InterruptedException ex)
                {
                    ex.printStackTrace();
                }
                // 修改余额
                account.setBalance(account.getBalance() - drawAmount);
                System.out.println("t余额为: " + account.getBalance());
            }
            else
            {
                System.out.println(getName() + "取钱失败!余额不足!");
            }
        }
        // 同步代码块结束,该线程释放同步锁
    }
}

这种做法符合"加锁---修改---释放锁"的逻辑,任何线程在修改指定资源之前,首先对该资源加锁,在加锁期间其他线程无法修改该资源,当线程修改完成后,该线程释放对该资源的锁定.
通过这种方式可以保证并发线程在同一时刻只有一个线程可以进入修改共享资源的代码区(也被称为临界区),所以同一时刻最多只有一个线程处于临界区内,从而保证了线程的安全性.

同步方法

Java多线程还提供了同步方法来和同步代码块相对应,使用synchronized字来修饰某个方法,该方法称为同步方法.对于synchronized关键字修饰的实例方法,无须显式指定同步监视器,同步方法的同步监视器是this,也就是调用该方法的对象.

通过同步方法可以非常方便的实现线程安全的类,线程安全的类具有如下特征:

  • 1.该类的对象可以被多个线程安全地访问
  • 2.每个线程调用该对象的任意方法之后都能得到正确结果
  • 3.每个线程调用该对象的任意方法之后,该对象状态依然保持合理的状态

不可变类总是线程安全的,因为它的对象时不可变的;但可变对象需要额外的方法来保证其线程安全.

public class Account
{
    // 封装账户编号、账户余额两个成员变量
    private String accountNo;
    private double balance;
    public Account(){}
    // 构造器
    public Account(String accountNo , double balance)
    {
        this.accountNo = accountNo;
        this.balance = balance;
    }

    // accountNo的setter和getter方法
    public void setAccountNo(String accountNo)
    {
        this.accountNo = accountNo;
    }
    public String getAccountNo()
    {
        return this.accountNo;
    }
    // 因此账户余额不允许随便修改,所以只为balance提供getter方法,
    public double getBalance()
    {
        return this.balance;
    }

    // 提供一个线程安全draw()方法来完成取钱操作
    public synchronized void draw(double drawAmount)
    {
        // 账户余额大于取钱数目
        if (balance >= drawAmount)
        {
            // 吐出钞票
            System.out.println(Thread.currentThread().getName()
                + "取钱成功!吐出钞票:" + drawAmount);
            try
            {
                Thread.sleep(1);
            }
            catch (InterruptedException ex)
            {
                ex.printStackTrace();
            }
            // 修改余额
            balance -= drawAmount;
            System.out.println("t余额为: " + balance);
        }
        else
        {
            System.out.println(Thread.currentThread().getName()
                + "取钱失败!余额不足!");
        }
    }

    // 下面两个方法根据accountNo来重写hashCode()和equals()方法
    public int hashCode()
    {
        return accountNo.hashCode();
    }
    public boolean equals(Object obj)
    {
        if(this == obj)
            return true;
        if (obj !=null
            && obj.getClass() == Account.class)
        {
            Account target = (Account)obj;
            return target.getAccountNo().equals(accountNo);
        }
        return false;
    }
}

增加了一个代表取钱的draw()方法,并使用synchronized关键字来修饰该方法,把该方法编程同步方法,该同步方法的同步监视器是this,对于同一个Account账户而言,任意时刻只能有一个线程获得对Account对象的锁定,然后进入draw()方法执行取钱操作-----这样也可以保证多个线程并发取钱的线程安全.

注意:synvhronized关键字可以修饰方法,可以修饰代码块,但不能修饰构造器,成员变量等等.

public class DrawThread extends Thread
{
    // 模拟用户账户
    private Account account;
    // 当前取钱线程所希望取的钱数
    private double drawAmount;
    public DrawThread(String name , Account account
        , double drawAmount)
    {
        super(name);
        this.account = account;
        this.drawAmount = drawAmount;
    }
    // 当多条线程修改同一个共享数据时,将涉及数据安全问题。
    public void run()
    {
        // 直接调用account对象的draw方法来执行取钱
        // 同步方法的同步监视器是this,this代表调用draw()方法的对象。
        // 也就是说:线程进入draw()方法之前,必须先对account对象的加锁。
        account.draw(drawAmount);
    }
}

在上面的示例中,调用draw()方法的对象是account,多个线程并发修改同一份account之前,必须先对account对象加锁,这也符合"加锁---修改---释放锁"的逻辑

面向对象中的一种流行的设计模式:
DDD(领域驱动设计):这种方式认为每个类都应该是完备的领域对象,比如:Account代表用户账户,应该提供用户账户的相关方法;通过draw()方法来执行取钱操作(实际上还应该提供transfer()等方法来完成转账等操作),而不是直接将setBalance()方法暴露出来任人操作,这样才能保证Account对象的完整性和一致性.

可变类的线程安全是以降低程序的运行效率作为代价的.

  • 1.不要堆线程安全类的所有方法进行同步,只对那些会改变竞争资源(竞争资源也就是共享资源)的方法进行同步.
  • 2.可变类有两种运行环境:单线程环境和多线程环境,则应该为该可变类提供两种版本,即线程不安全版本和线程安全版本.在单线程环境中使用线程不安全版本以保证性能(StringBuilder);在多线程中使用线程安全的版本(StringBuffer)

释放同步监视器的锁定

程序无须显式释放对同步监视器的锁定,线程会在如下几种情况下释放对同步监视器的锁定

  • 1.当前线程的同步方法,同步代码块执行结束
  • 2.当前线程在同步代码块,同步方法中遇到break,return终止了代码块导致其异常结束
  • 3.当前线程在同步代码块,同步方法中出现了未处理的ErrorException
  • 4.当前线程执行同步代码块和同步方法时,程序执行了同步监视器对象的wait()方法,当前线程暂停,并释放同步监视器

下面出现的情况,线程不会释放同步监视器

  • 1.当前线程在执行同步代码块,同步方法时,程序调用了Thread.sleep(),Thread.yield()方法来暂停当前线程的执行,当前线程并不会释放同步监视器
  • 2.线程在执行同步代码块时,其他线程调用了该线程的suspend()方法将该线程挂起,该线程不会释放同步监视器.程序应该尽量避免使用suspend()resume()方法来控制线程.

同步锁

通过显式定义同步锁对象来实现同步-----同步锁对象由Lock对象充当.(这是一种更为强大的线程同步机制)
Lock是控制多个线程对共享资源进行访问的工具,每次只能有一个线程对Lock对象加锁,程序开始访问共享资源之前首先要先获得Lock对象
某些锁可能允许对共享资源并发访问,如ReadWriteLock(读写锁);
Lock,ReadWriteLockJava5提供的两个根接口,并为Lock提供了ReentrantLock(可重入锁)实现类,为ReadWriteLock提供了ReentrantReadWriteLock实现类
ReentrantReadWriteLock为读写提供了三种锁模式:Writing,ReadingOptimistic,Reading
在实现线程安全的控制中比较常用的是ReentrantLock(可重入锁).使用该Lock对象可以显式地释放锁,加锁.

import java.util.concurrent.locks.*;
public class Account
{
    // 定义锁对象
    private final ReentrantLock lock = new ReentrantLock();
    //.......
    // 提供一个线程安全draw()方法来完成取钱操作(定义一个保证线程安全的方法)
    public void draw(double drawAmount)
    {
        // 加锁
        lock.lock();
        try
        {
            // 账户余额大于取钱数目
            if (balance >= drawAmount)
            {
                // 吐出钞票
                System.out.println(Thread.currentThread().getName()
                    + "取钱成功!吐出钞票:" + drawAmount);
                try
                {
                    Thread.sleep(1);
                }
                catch (InterruptedException ex)
                {
                    ex.printStackTrace();
                }
                // 修改余额
                balance -= drawAmount;
                System.out.println("t余额为: " + balance);
            }
            else
            {
                System.out.println(Thread.currentThread().getName()
                    + "取钱失败!余额不足!");
            }
        }
        finally
        {
            // 修改完成,释放锁
            lock.unlock();
        }
    }

    // 下面两个方法根据accountNo来重写hashCode()和equals()方法
    public int hashCode()
    {
        return accountNo.hashCode();
    }
    public boolean equals(Object obj)
    {
        if(this == obj)
            return true;
        if (obj !=null
            && obj.getClass() == Account.class)
        {
            Account target = (Account)obj;
            return target.getAccountNo().equals(accountNo);
        }
        return false;
    }
}

使用ReentrantLock对象来进行同步,加锁和释放锁出现在不同的作用范围内时,通常建议使用finally块来确保在必要时释放锁.
程序中实现draw()方法时,进入方法开始执行后立即请求对ReentrantLock对象进行加锁,当执行完draw()方法的取钱逻辑后,程序使用finally块确保释放锁.
使用Lock时是显式调用Lock对象作为同步锁,而使用同步方法时系统隐式地使用当前对象作为同步监视器,同样都符合"加锁---修改---释放锁"的操作模式,而且Lock对象时每个Lock对象都对应一个Account对象,一样可以保证对于同一个Account对象,同一时刻只能有一个线程能进入临界区

ReentrantLock锁具有可重入性,一个线程可以对已加锁的ReentrantLock锁再次加锁,ReentrantLock对象会维持一个计数器来追踪lock()方法的嵌套使用,线程在每次调用lock()方法加锁后,必须显式调用unlock()方法来释放锁,所以一段被锁保护的代码可以调用另一个被相同锁保护的方法.

死锁

当两个线程互相等待对方释放同步监视器时就会发生死锁.Java没有提供任何检测措施来处理死锁的情况,所以多线程编程时应该尽量采取措施来避免死锁的出现.一旦出现死锁,整个程序既不会发生任何异常,也不会给出任何提示,只是所有线程处于堵塞状态,无法继续.

死锁很容易发生,尤其是在系统中出现多个同步监视器的情况下:

class A
{
    public synchronized void foo( B b )
    {
        System.out.println("当前线程名: " + Thread.currentThread().getName()
            + " 进入了A实例的foo()方法" );     // ①
        try
        {
            Thread.sleep(200);
        }
        catch (InterruptedException ex)
        {
            ex.printStackTrace();
        }
        System.out.println("当前线程名: " + Thread.currentThread().getName()
            + " 企图调用B实例的last()方法");    // ③
        b.last();
    }
    public synchronized void last()
    {
        System.out.println("进入了A类的last()方法内部");
    }
}
class B
{
    public synchronized void bar( A a )
    {
        System.out.println("当前线程名: " + Thread.currentThread().getName()
            + " 进入了B实例的bar()方法" );   // ②
        try
        {
            Thread.sleep(200);
        }
        catch (InterruptedException ex)
        {
            ex.printStackTrace();
        }
        System.out.println("当前线程名: " + Thread.currentThread().getName()
            + " 企图调用A实例的last()方法");  // ④
        a.last();
    }
    public synchronized void last()
    {
        System.out.println("进入了B类的last()方法内部");
    }
}
public class DeadLock implements Runnable
{
    A a = new A();
    B b = new B();
    public void init()
    {
        Thread.currentThread().setName("主线程");
        // 调用a对象的foo方法
        a.foo(b);
        System.out.println("进入了主线程之后");
    }
    public void run()
    {
        Thread.currentThread().setName("副线程");
        // 调用b对象的bar方法
        b.bar(a);
        System.out.println("进入了副线程之后");
    }
    public static void main(String[] args)
    {
        DeadLock dl = new DeadLock();
        // 以dl为target启动新线程
        new Thread(dl).start();
        // 调用init()方法
        dl.init();
    }
}

Thread类的suspend()方法也容易导致死锁,Java不推介使用该方法来暂停线程的执行.

线程通信

程序通常无法准确控制线程的轮换执行,但Java也提供了一些机制来保证线程协调运行.

传统的线程通信

Object类提供的三个方法(这三个方法必须由同步监视器对象来调用):
同步监视器对象可以分为下列两种情况:

  • 1.使用synchronized修饰的同步方法,该类的默认实例(this)就是同步监视器.
  • 2.使用synchronized修饰的同步代码块,同步监视器是synchronized后括号里的对象

这三个方法解释如下:
wait():导致当前线程等待,直到其它线程调用该同步监视器的notify()方法或notifyAll()方法来唤醒该线程.调用wait()方法的当前线程会释放对该同步监视器的锁定.
notify():唤醒此同步监视器上等待的单个线程.只有当前线程放弃对该同步监视器的锁定后(使用wait()方法),才可以执行被唤醒的线程.
notifyAll():唤醒在此同步监视器上等待的所有线程.只有当前线程放弃对该同步监视器的锁定后,才可以执行被唤醒的线程.

public class Account
{
    // 封装账户编号、账户余额的两个成员变量
    private String accountNo;
    private double balance;
    // 标识账户中是否已有存款的旗标
    private boolean flag = false;

    public Account(){}
    // 构造器
    public Account(String accountNo , double balance)
    {
        this.accountNo = accountNo;
        this.balance = balance;
    }

    // accountNo的setter和getter方法
    public void setAccountNo(String accountNo)
    {
        this.accountNo = accountNo;
    }
    public String getAccountNo()
    {
        return this.accountNo;
    }
    // 因此账户余额不允许随便修改,所以只为balance提供getter方法,
    public double getBalance()
    {
        return this.balance;
    }

    public synchronized void draw(double drawAmount)
    {
        try
        {
            // 如果flag为假,表明账户中还没有人存钱进去,取钱方法阻塞
            if (!flag)
            {
                wait();
            }
            else
            {
                // 执行取钱
                System.out.println(Thread.currentThread().getName()
                    + " 取钱:" +  drawAmount);
                balance -= drawAmount;
                System.out.println("账户余额为:" + balance);
                // 将标识账户是否已有存款的旗标设为false。
                flag = false;
                // 唤醒其他线程
                notifyAll();
            }
        }
        catch (InterruptedException ex)
        {
            ex.printStackTrace();
        }
    }
    public synchronized void deposit(double depositAmount)
    {
        try
        {
            // 如果flag为真,表明账户中已有人存钱进去,则存钱方法阻塞
            if (flag)             //①
            {
                wait();
            }
            else
            {
                // 执行存款
                System.out.println(Thread.currentThread().getName()
                    + " 存款:" +  depositAmount);
                balance += depositAmount;
                System.out.println("账户余额为:" + balance);
                // 将表示账户是否已有存款的旗标设为true
                flag = true;
                // 唤醒其他线程
                notifyAll();
            }
        }
        catch (InterruptedException ex)
        {
            ex.printStackTrace();
        }
    }

    // 下面两个方法根据accountNo来重写hashCode()和equals()方法
    public int hashCode()
    {
        return accountNo.hashCode();
    }
    public boolean equals(Object obj)
    {
        if(this == obj)
            return true;
        if (obj !=null
            && obj.getClass() == Account.class)
        {
            Account target = (Account)obj;
            return target.getAccountNo().equals(accountNo);
        }
        return false;
    }
}
public class DrawThread extends Thread
{
    // 模拟用户账户
    private Account account;
    // 当前取钱线程所希望取的钱数
    private double drawAmount;
    public DrawThread(String name , Account account
        , double drawAmount)
    {
        super(name);
        this.account = account;
        this.drawAmount = drawAmount;
    }
    // 重复100次执行取钱操作
    public void run()
    {
        for (int i = 0 ; i < 100 ; i++ )
        {
            account.draw(drawAmount);
        }
    }
}
public class DepositThread extends Thread
{
    // 模拟用户账户
    private Account account;
    // 当前取钱线程所希望存款的钱数
    private double depositAmount;
    public DepositThread(String name , Account account
        , double depositAmount)
    {
        super(name);
        this.account = account;
        this.depositAmount = depositAmount;
    }
    // 重复100次执行存款操作
    public void run()
    {
        for (int i = 0 ; i < 100 ; i++ )
        {
            account.deposit(depositAmount);
        }
    }
}
public class DrawTest
{
    public static void main(String[] args)
    {
        // 创建一个账户
        Account acct = new Account("1234567" , 0);
        new DrawThread("取钱者" , acct , 800).start();
        new DepositThread("存款者甲" , acct , 800).start();
        new DepositThread("存款者乙" , acct , 800).start();
        new DepositThread("存款者丙" , acct , 800).start();
    }
}

上图所示的是堵塞而不是死锁,取钱者的线程已经执行结束,但是存钱者的线程只是在等待其他线程来取钱而已,并不是等待其他线程释放同步监视器,不要把死锁和程序堵塞等同起来.

使用Condition控制线程通信

如果程序使用Lock对象保证同步,则系统中不存在隐式地同步监视器,也就不能用wait(),notify(),notifyAll()方法进行线程通信了.
当使用Lock对象来保证同步时,Java提供了一个Condition类来保持协调,使用Conditon可以让那些已经得到Lock对象却无法继续执行的线程释放Lock对象,Conditon对象也可以唤醒其它处于等待的线程.
Conditon将同步监视器方法(wait(),notify(),notifyAll())分解成不同的对象,以便通过将这些对象和Lock对象组合使用,为每个对象提供多个等待集(wait-set).Lock替代了同步方法或同步代码块,Conditon替代了同步监视器的功能.

Conditon实例绑定在一个Lock对象上,要获得特定Lock实例的Conditon实例,调用Lock对象的newConditon()方法即可.

Conditon类提供了如下三个方法:
await():类似于隐式同步器上的wait()方法,导致当前线程等待,直到其它线程调用该Conditonsignal()方法或signalAll()方法来唤醒线程.
signal():唤醒在此Lock对象上等待的单个线程.只有当前线程放弃对该Lock对象的锁定后(使用await()方法),才可以执行被唤醒线程
signalAll():唤醒在此Lock对象上等待的所有线程.只有当前线程放弃对该Lock对象的锁定后(使用await()方法),才可以执行被唤醒线程

下面程序通过Account使用Lock对象来控制同步,并使用Conditon对象来控制线程的协调运行.

public class Account
{
    // 显式定义Lock对象
    private final Lock lock = new ReentrantLock();
    // 获得指定Lock对象对应的Condition
    private final Condition cond  = lock.newCondition();
    // 封装账户编号、账户余额的两个成员变量
    private String accountNo;
    private double balance;
    // 标识账户中是否已有存款的旗标
    private boolean flag = false;

    public Account(){}
    // 构造器
    public Account(String accountNo , double balance)
    {
        this.accountNo = accountNo;
        this.balance = balance;
    }

    // accountNo的setter和getter方法
    public void setAccountNo(String accountNo)
    {
        this.accountNo = accountNo;
    }
    public String getAccountNo()
    {
        return this.accountNo;
    }
    // 因此账户余额不允许随便修改,所以只为balance提供getter方法,
    public double getBalance()
    {
        return this.balance;
    }

    public void draw(double drawAmount)
    {
        // 加锁
        lock.lock();
        try
        {
            // 如果flag为假,表明账户中还没有人存钱进去,取钱方法阻塞
            if (!flag)
            {
                cond.await();
            }
            else
            {
                // 执行取钱
                System.out.println(Thread.currentThread().getName()
                    + " 取钱:" +  drawAmount);
                balance -= drawAmount;
                System.out.println("账户余额为:" + balance);
                // 将标识账户是否已有存款的旗标设为false。
                flag = false;
                // 唤醒其他线程
                cond.signalAll();
            }
        }
        catch (InterruptedException ex)
        {
            ex.printStackTrace();
        }
        // 使用finally块来释放锁
        finally
        {
            lock.unlock();
        }
    }
    public void deposit(double depositAmount)
    {
        lock.lock();
        try
        {
            // 如果flag为真,表明账户中已有人存钱进去,则存钱方法阻塞
            if (flag)             // ①
            {
             //导致当前线程等待,知道其他线程调用该Conditon的signal()或signalAll()方法来唤醒该线程
                cond.await();
            }
            else
            {
                // 执行存款
                System.out.println(Thread.currentThread().getName()
                    + " 存款:" +  depositAmount);
                balance += depositAmount;
                System.out.println("账户余额为:" + balance);
                // 将表示账户是否已有存款的旗标设为true
                flag = true;
                // 唤醒其他线程
                cond.signalAll();
            }
        }
        catch (InterruptedException ex)
        {
            ex.p
                        
版权声明:本文来源简书,感谢博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://www.jianshu.com/p/87a5f9e41238
站方申明:本站部分内容来自社区用户分享,若涉及侵权,请联系站方删除。
  • 发表于 2020-01-08 22:04:18
  • 阅读 ( 970 )
  • 分类:Linux

0 条评论

请先 登录 后评论

官方社群

GO教程

猜你喜欢