fundamentals\java\Concurrency

线程和并发性简介

原文链接:Java Concurrency Essentials Tutorial

  • 关于线程的基本知识

并发性是一个程序同时执行多个计算的能力。这可以通过将计算分布在一台机器的可用CPU核心上,甚至在同一网络中的不同机器上实现。

为了更好地理解并行执行,我们必须区分进程和线程。进程是由操作系统提供的执行环境,具有自己的一组专用资源(例如内存、打开的文件等)。与此相反,线程是位于一个进程内并与该进程的其他线程共享其资源(内存、打开的文件等)的进程。

在不同线程之间共享资源的能力使线程更适合于需求更高性能的任务。虽然可以在同一台计算机上运行的不同进程之间,甚至在同一网络中的不同计算机上,建立进程间通信,但出于性能原因,通常选择线程在单个计算机上并行计算。

在Java中,进程对应于运行的Java虚拟机(JVM),而线程位于同一JVM中,并且可以在运行时动态地由Java应用程序创建和停止。每个程序至少有一个线程:主线程。这个主线程是在每个Java应用程序启动时创建的,它是调用程序的main()方法的那个线程。从这一点开始,Java应用程序可以创建新线程并与它们一起工作。

这在下面的源代码中进行了演示。通过jdk类java.lang.thread的静态方法currenthread()提供对当前线程的访问:

public class MainThread {

    

    public static void main(String[] args) {

        long id = Thread.currentThread().getId();

        String name = Thread.currentThread().getName();

        int priority = Thread.currentThread().getPriority();

        State state = Thread.currentThread().getState();

        String threadGroupName = Thread.currentThread().getThreadGroup().getName();

        System.out.println("id="+id+"; name="+name+"; priority="+priority+"; state="+state+"; threadGroupName="+threadGroupName);

    }

}

 

 

从这个简单应用程序的源代码中可以看到,我们直接在main()方法中访问当前线程,并打印出有关它的一些信息:

id=1; name=main; priority=5; state=RUNNABLE; threadGroupName=main

 

输出显示了关于每个线程的一些有趣信息。每个线程都有一个标识符,它在JVM中是唯一的。线程的名称有助于在监视正在运行的JVM的外部应用程序(例如调试器或JConsole工具)中查找某些线程。当执行多个线程时,优先级决定下一个应该执行哪个任务。

关于线程的事实是,并非所有线程都是同时执行的,但是每个CPU核心上的执行时间被划分为小块,下一个时间片被分配给具有最高优先级的下一个等待线程。JVM的调度程序根据线程的优先级决定下一个执行哪个线程。

除了优先级旁边,线程还具有一个状态,可以是以下状态之一:

NEW尚未启动的线程处于此状态。

RUNNABLE: 在Java虚拟机中执行的线程处于该状态。

BLOCKED: 阻塞的线程处于等待控制锁释放的状态。

WAITING: 无限期等待另一个线程执行特定操作的线程处于此状态。

TIMED_WAITING: 在指定的等待时间内等待另一个线程执行操作的线程处于此状态。

TERMINATED: 已退出的线程处于此状态。

上面例子中的主线程当然处于RUNNABLE状态。有blocked、WAITING等等这么多状态,表明线程管理是一个高级主题。如果处理不当,线程会相互阻塞,从而导致应用程序挂起。但我们稍后会讨论这个问题。

最后但并非最不重要的是,线程的属性threadgroup指示线程是分组管理的。每个线程属于一组线程。jdk类java.lang.threadgroup提供了一些处理整个线程组的方法。使用这些方法,我们可以中断一个组的所有线程,或者设置它们的最大优先级。

 

  • 创建和启动线程

现在我们已经仔细研究了线程的属性,现在是创建和启动第一个线程的时候了。基本上有两种在Java中创建线程的方法。第一个是编写一个java.lang.thread的扩展:

public class MyThread extends Thread {

    

    public MyThread(String name) {

        super(name);

    }

 

    @Override

    public void run() {

        System.out.println("Executing thread "+Thread.currentThread().getName());

    }

    

    public static void main(String[] args) throws InterruptedException {

        MyThread myThread = new MyThread("myThread");

        myThread.start();

    }

}

 

如上所示,类mythread扩展了Thread类并重写方法run()。一旦虚拟机启动线程,就会执行run()方法。由于虚拟机必须做一些工作才能为线程设置执行环境,因此我们不能直接调用此方法来启动线程。相反,我们在类mythread的一个实例上调用方法start()。当这个类从它的超类继承方法stop()时,这个方法后面的代码告诉jvm为线程分配所有必要的资源并启动它。当我们运行上面的代码时,我们会看到输出“Executing thread mythread”。与我们的介绍示例不同,run()方法中的代码不是在“main”线程中执行的,而是在我们自己的线程“mythread”中执行的。

创建线程的第二种方法是实现接口Runnable

public class MyRunnable implements Runnable {

 

    public void run() {

        System.out.println("Executing thread "+Thread.currentThread().getName());

    }

    

    public static void main(String[] args) throws InterruptedException {

        Thread myThread = new Thread(new MyRunnable(), "myRunnable");

        myThread.start();

    }

}

 

与子类化方法的主要区别在于,我们是创建java.lang.thread的实例,还是提供一个实现Runable接口的实例作为线程构造函数的参数。在这个实例的旁边,我们还传递了线程的名称,以便在从命令行执行程序时看到以下输出:“Executing thread myRUnnable”。

使用继承Thread还是Runable接口方法来创建线程,这在一定程度上取决于您的品味。接口是一种更轻量的方法,因为您所要做的就是实现接口。类仍然可以是其他类的子类。您还可以将自己的参数传递给构造函数,而子类化线程将限制您使用Thread类的可用构造函数。

  • 线程的Sleeping和中断(interrupting)

一旦我们启动了一个线程,它就会一直运行,直到run()方法到达它的末尾。在上面的示例中,run()方法只不过打印出当前线程的名称。因此,线很快就完成了。

在现实世界中的应用程序中,通常必须实现某种后台处理,让线程运行,直到它处理完成,比如处理完成目录结构中的所有文件。另一个常见的用例是有一个后台线程,该线程每N秒检查一次,如果发生了什么事情(例如,创建了一个文件),则启动某种操作。在这种情况下,您必须等待N秒或毫秒。您可以通过使用while循环来实现这一点,该循环的主体获取当前毫秒数,检查是否达到下一次运行时间。虽然这样的实现可以工作,但是它浪费了CPU处理时间,因为您的线程会占用CPU并一次又一次地检索当前的时间。

对于此类用例,更好的方法是调用类java.lang.thread的方法sleep(),如下例所示:

public void run() {

    while(true) {

        doSomethingUseful();

        try {

            Thread.sleep(1000);

        } catch (InterruptedException e) {

            e.printStackTrace();

        }

    }

}

 

调用sleep()将当前线程置于休眠状态,而不会占用任何处理时间。这意味着当前线程将自己从活动线程列表中删除,并且调度程序在指定时间内(以毫秒为单位)不会计划执行它。

请注意,传递给sleep()方法的时间只是给调度程序的指示,而不是绝对准确的时间范围。实际执行的调度,线程可能会在几纳秒或几毫秒之前或之后执行。因此,不应将此方法用于实时调度目的。但是对于大多数用例来说,所获得的准确度是足够的。

在上面的代码示例中,您可能注意到sleep()可能抛出的interruptedException。中断是线程交互的一个非常基本的特性,可以理解为一个线程发送到另一个线程的简单中断消息。接收线程可以通过调用方法thread.interrupted()显式地询问它是否被中断,或者在被sleep()方法隐式中断,sleep()方法在中断时引发异常。

让我们用下面的代码示例更详细地了解中断:

public class InterruptExample implements Runnable {

 

    public void run() {

        try {

            Thread.sleep(Long.MAX_VALUE);

        } catch (InterruptedException e) {

            System.out.println("["+Thread.currentThread().getName()+"] Interrupted by exception!");

        }

        while(!Thread.interrupted()) {

            // do nothing here

        }

        System.out.println("["+Thread.currentThread().getName()+"] Interrupted for the second time.");

    }

 

    public static void main(String[] args) throws InterruptedException {

        Thread myThread = new Thread(new InterruptExample(), "myThread");

        myThread.start();

        

        System.out.println("["+Thread.currentThread().getName()+"] Sleeping in main thread for 5s...");

        Thread.sleep(5000);

        

        System.out.println("["+Thread.currentThread().getName()+"] Interrupting myThread");

        myThread.interrupt();

        

        System.out.println("["+Thread.currentThread().getName()+"] Sleeping in main thread for 5s...");

        Thread.sleep(5000);

        

        System.out.println("["+Thread.currentThread().getName()+"] Interrupting myThread");

        myThread.interrupt();

    }

}

 

在main方法中,我们首先启动一个新线程,如果它不被中断,它将休眠很长时间(大约290000年)。为了在这段时间过去之前完成程序,Mythread将通过在方法中调用interrupt()来中断。这将导致在sleep()方法中发生InterruptedException,并在控制台上打印为“Interrupted by exception!”在记录了异常之后,线程会while等待,直到主线程通过在线程的实例变量上调用interrupt(),在线程上设置了interrupted标志。总的来说,我们在控制台上看到以下输出:

[main] Sleeping in main thread for 5s...

[main] Interrupting myThread

[main] Sleeping in main thread for 5s...

[myThread] Interrupted by exception!

[main] Interrupting myThread

[myThread] Interrupted for the second time.

 

这个输出中有趣的是第3行和第4行。如果我们检查代码,我们可能会期望看到字符串“被异常中断!在主线程重新开始休眠之前打印出“在主线程中休眠5秒…”。但是从输出中可以看到,调度程序在再次启动Mythread之前已经执行了主线程。因此,Mythread在主线程开始休眠后打印出异常的接收。

当用多个线程编程时,记录线程的输出来预测,计算下一个执行的线程是非常困难的。当你不得不处理更多的线程时,情况会变得更糟,这些线程的停顿不像上面的例子那样属于硬编码。在这些情况下,整个程序处于某种内部动态变化中,这使得并发编程成为一项具有挑战性的任务。

 

  • 线程的Join

正如我们在上一节中看到的,我们可以让线程休眠,直到被另一个线程唤醒。线程的另一个重要特性是线程等待另一个线程终止的能力。

假设您必须实现某种可以划分为多个并行运行线程的数字处理操作。启动所谓的工作线程的主线程必须等到其所有子线程都终止。以下代码显示了如何实现这一点:

public class JoinExample implements Runnable {

    private Random rand = new Random(System.currentTimeMillis());

 

    public void run() {

        //simulate some CPU expensive task

        for(int i=0; i<100000000; i++) {

            rand.nextInt();

        }

        System.out.println("["+Thread.currentThread().getName()+"] finished.");

    }

 

    public static void main(String[] args) throws InterruptedException {

        Thread[] threads = new Thread[5];

        for(int i=0; i<threads.length; i++) {

            threads[i] = new Thread(new JoinExample(), "joinThread-"+i);

            threads[i].start();

        }

        for(int i=0; i<threads.length; i++) {

            threads[i].join();

        }

        System.out.println("["+Thread.currentThread().getName()+"] All threads have finished.");

    }

}

 

在我们的main方法中,我们创建了一个由五个线程组成的数组,这些线程都是依次启动的。一旦我们启动了它们,我们就在主线程中等待它们的终止。线程本身通过依次计算一个随机数来模拟一些数字处理。完成后,打印出“完成”。最后,主线程确认其所有子线程的终止:

[joinThread-4] finished.

[joinThread-3] finished.

[joinThread-2] finished.

[joinThread-1] finished.

[joinThread-0] finished.

[main] All threads have finished.

 

您将注意到“已完成”消息的顺序因执行而异。如果您多次执行该程序,您可能会看到第一个完成的线程并不总是相同的。但最后一条语句始终是等待其子线程的主线程。

  • 同步

正如我们在最后的例子中看到的,执行所有运行线程的确切顺序取决于线程配置,如优先级,也取决于可用的CPU资源,以及调度程序选择下一个线程执行的方式。尽管调度程序的行为是完全确定的,但是很难预测在给定时间点执行哪个线程。这使得对共享资源的访问变得至关重要,因为很难预测哪个线程将是第一个尝试访问它的线程。通常对共享资源的访问是独占的,这意味着在给定的时间点上只有一个线程可以访问该资源,而不会有任何其他线程干扰该访问。

并发访问独占资源的一个简单示例是一个静态变量,该变量由多个线程递增:

public class NotSynchronizedCounter implements Runnable {

    private static int counter = 0;

 

    public void run() {

        while(counter < 10) {

            System.out.println("["+Thread.currentThread().getName()+"] before: "+counter);

            counter++;

            System.out.println("["+Thread.currentThread().getName()+"] after: "+counter);

        }

    }

 

    public static void main(String[] args) throws InterruptedException {

        Thread[] threads = new Thread[5];

        for(int i=0; i<threads.length; i++) {

            threads[i] = new Thread(new NotSynchronizedCounter(), "thread-"+i);

            threads[i].start();

        }

        for(int i=0; i<threads.length; i++) {

            threads[i].join();

        }

    }

}

 

当我们仔细观察这个简单应用程序的输出时,我们会看到如下内容:

[thread-2] before: 8

[thread-2] after: 9

[thread-1] before: 0

[thread-1] after: 10

[thread-2] before: 9

[thread-2] after: 11

 

现在,您将看到将计数器变量加一之前和之后的输出。

synchronized关键字可以以两种不同的方式使用。它可以在如上所示的方法中使用。在这种情况下,必须提供一个临界资源用来锁定当前线程。必须仔细选择这个临界资源,因为线程锁定根据变量的范围变得完全不同。

如果变量是当前类的成员,则所有线程都将针对该类的实例进行同步,因为每个localsync实例都存在变量sync:

public class LocalSync {

    private Integer sync = 0;

 

    public void someMethod() {

        synchronized (sync) {

            // synchronized on instance level

        }

    }

}

 

您也可以将同步关键字添加到方法签名中,而不是创建覆盖整个方法体的块。下面的代码与上面的代码具有相同的效果:

public class MethodSync {

    private Integer sync = 0;

 

    public synchronized void someMethod() {

        // synchronized on instance level

    }

}

 

这两种方法的主要区别在于,第一种方法粒度更细,因为您可以使同步块小于方法体。请记住,同步块一次只能由一个线程执行,因此每个同步块都是一个潜在的性能问题,因为所有并发运行的线程可能需要等待,直到当前线程离开该块。因此,我们应该尽可能地缩小这个块。

大多数情况下,您必须同步对每个JVM只存在一次的某些资源的访问。常用的方法是使用类的静态成员变量:

public class StaticSync {

    private static Integer sync = 0;

 

    public void someMethod() {

        synchronized (sync) {

            // synchronized on ClassLoader/JVM level

        }

    }

}

 

上面的代码同步在同一个JVM中运行方法somemethod()的所有线程,因为静态变量在同一个JVM中只存在一次。正如您可能知道的,如果类是由同一个类装入器装入的,那么它在一个JVM中是唯一的。如果使用多个类加载器加载类staticsync,则静态变量存在多次。但在大多数日常应用程序中,不会有多个加载同一类两次的类加载器,因此可以假定静态变量只存在一次,因此同一个JVM中的所有线程都必须等待,直到获得锁为止。

 

  • 原子性

在上一节中,我们看到了当许多并发线程执行代码的某一部分时,如何同步对某些复杂资源的访问,使得每个时间点只有一个线程执行。我们还看到,如果不同步的访问公共资源,对这些资源的交错操作可能会导致非法的结果。

Java语言提供了一些基本的操作,这些操作是原子的,因此可以用来确保并发线程总是看到相同的值:

  • ·对引用变量和基元变量的读写操作(long和double除外)
  • ·对声明为volatile的所有变量执行读写操作

为了更详细地理解这一点,我们假设有一个HashMap,其中填充了从文件中读取的属性,以及一组处理这些属性的线程。很明显,我们需要在这里进行某种同步,因为读取文件和更新映射的过程需要花费时间,而在此期间执行其他线程正并发执行。

我们不能简单的在所有线程之间共享此Map的一个实例并在此Map上工作执行更新操作。这将导致由访问线程读取的Map处于不正确的状态,而该状态。根据上一节的知识,我们当然可以在映射的每个访问(读/写)使用同步块,以确保所有线程处于同步状态,而不是部分更新Map。但是,如果并发线程对Map的操作比较频繁,这会导致性能问题。

克隆同步块中每个线程的Map,并让每个线程在单独的副本上工作也是一个解决方案。但是每个线程都必须不时地请求更新副本,并且副本占用内存,同步块和克隆都可行。但有一个更简单的解决方案。

因为我们知道对引用的写操作是原子操作,所以我们可以在每次读取文件时创建一个新Map,并在一个原子操作中更新线程之间共享的引用。在这个实现中,工作线程永远不会读取不一致的Map,因为Map是使用原子操作更新的:

public class AtomicAssignment implements Runnable {

    private static volatile Map<String, String> configuration = new HashMap<String, String>();

 

    public void run() {

        for (int i = 0; i < 10000; i++) {

            Map<String, String> currConfig = configuration;

            String value1 = currConfig.get("key-1");

            String value2 = currConfig.get("key-2");

            String value3 = currConfig.get("key-3");

            if (!(value1.equals(value2) && value2.equals(value3))) {

                throw new IllegalStateException("Values are not equal.");

            }

            try {

                Thread.sleep(10);

            } catch (InterruptedException e) {

                e.printStackTrace();

            }

        }

    }

 

    public static void readConfig() {

        Map<String, String> newConfig = new HashMap<String, String>();

        Date now = new Date();

        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss:SSS");

        newConfig.put("key-1", sdf.format(now));

        newConfig.put("key-2", sdf.format(now));

        newConfig.put("key-3", sdf.format(now));

        configuration = newConfig;

    }

 

    public static void main(String[] args) throws InterruptedException {

        readConfig();

        Thread configThread = new Thread(new Runnable() {

            public void run() {

                for (int i = 0; i < 10000; i++) {

                    readConfig();

                    try {

                        Thread.sleep(10);

                    } catch (InterruptedException e) {

                        e.printStackTrace();

                    }

                }

            }

        }, "configuration-thread");

        configThread.start();

        Thread[] threads = new Thread[5];

        for (int i = 0; i < threads.length; i++) {

            threads[i] = new Thread(new AtomicAssignment(), "thread-" + i);

            threads[i].start();

        }

        for (int i = 0; i < threads.length; i++) {

            threads[i].join();

        }

        configThread.join();

        System.out.println("[" + Thread.currentThread().getName() + "] All threads have finished.");

    }

}

 

上面的例子有点复杂,但不难理解。该Map是共享的,并且被AtomicAssignment的修饰。在main()方法中,我们首先读取配置一次,然后使用相同的值(这里是当前时间,包括毫秒)向映射添加三个键。然后我们启动一个“配置线程”,通过向映射中添加三次当前时间戳来模拟配置的读取。然后,五个工作线程使用配置变量读取Map,并比较这三个值。如果它们不相等,则抛出非法状态异常。

您可以运行程序一段时间,并且不会看到任何非法状态异常。这是因为我们在一个原子操作中将新映射分配给共享配置变量:

configuration = newConfig;

 

我们还可以在一个原子步骤中读取共享变量的值:

Map<String, String> currConfig = configuration;

 

由于这两个步骤都是原子步骤,所以我们将始终获得正确的Map实例的引用,其中所有三个值都相等。如果以直接使用配置变量而不是首先将其复制到局部变量的方式更改run()方法,则很快就会看到IllegalstateExceptions,因为配置变量始终指向“当前”配置。当它被配置线程更改后,对Map的后续读取访问将已经读取新值,并将其与旧Map中的值进行比较。

如果您直接在配置变量上使用readconfig()方法,而不是创建一个新映射,并在一个原子操作中将其分配给共享变量,那么情况也是如此。但可能需要一些时间,直到你看到第一个非法状态异常。对于所有使用多线程的应用程序来说都是如此。乍一看并发性问题并不总是可见的,但它们需要在压力测试条件下进行一段时间才能出现。

未完待续......

 

 

 

 

  • 3333

发布了26 篇原创文章 · 获赞 4 · 访问量 2541

猜你喜欢

转载自blog.csdn.net/u012296499/article/details/89445269
今日推荐