>类库下载 >java类库 >Java 프로그램 개선을 위한 151가지 팁

Java 프로그램 개선을 위한 151가지 팁

高洛峰
高洛峰원래의
2016-10-19 09:43:431944검색

권장사항 123: 휘발성은 데이터 동기화를 보장할 수 없습니다

휘발성 키워드는 두 가지 이유로 상대적으로 거의 사용되지 않습니다. 첫째, Java 1.5 이전에는 이 키워드가 성능에 따라 다른 의미를 가졌습니다. 휴대성이 나쁘고 디자인하기가 더 어렵고 오용도 많아지며 이로 인해 "평판"이 손상됩니다.

우리는 각 스레드가 스택 메모리에서 실행되며 각 스레드에는 자체 작업 메모리(레지스터, 캐시 등과 같은 작업 메모리)가 있음을 알고 있습니다. 스레드 계산은 일반적으로 작업을 통해 수행됩니다.

Java 프로그램 개선을 위한 151가지 팁

회로도를 보면 스레드가 필요한 변수 값을 주 메모리에서 작업 메모리로 로드한 다음 스레드가 실행 중인 경우에는 작업 메모리에서 직접 읽고, 쓰는 경우에는 먼저 작업 메모리에 쓴 다음 주 메모리에 새로 고칩니다. 이것은 JVM 메모리의 간단한 대답입니다. 모델이지만 이러한 구조는 다중 스레드 상황에서 문제를 일으킬 수 있습니다. 예를 들어 스레드 A는 변수 값을 수정하고 이를 주 메모리에 새로 고치지만 스레드 B와 C는 이 시간 동안 여전히 이 스레드의 값을 읽습니다. 즉, 작업 메모리는 가장 "최신" 값을 읽지 않습니다. 이때 서로 다른 스레드가 보유한 공용 리소스는 동기화되지 않습니다.

이 문제를 해결하기 위해 동기화된 코드 블록을 사용하거나 잠금 잠금을 사용하는 등 이러한 종류의 문제에 대한 많은 솔루션이 있습니다. 그러나 Java는 다음과 같이 휘발성을 사용하여 이러한 종류의 문제를 더 간단하게 해결할 수 있습니다. 변수 앞에 휘발성 키워드를 추가하면 각 스레드의 로컬 변수 액세스 및 수정이 이 스레드의 작업 메모리가 아닌 메모리와 직접 상호 작용하여 각 스레드가 가장 "새로운" 변수인 회로도를 얻을 수 있습니다.

Java 프로그램 개선을 위한 151가지 팁

이제 휘발성 변수의 원리를 이해했으니 생각해 보겠습니다. 휘발성 변수가 데이터 동기화를 보장할 수 있을까요? 두 스레드가 동시에 휘발성 데이터를 수정하여 더티 데이터를 생성합니까? 다음 코드를 살펴보겠습니다.

class UnsafeThread implements Runnable {
    // 共享资源
    private volatile int count = 0;

    @Override
    public void run() {
        // 增加CPU的繁忙程度,不必关心其逻辑含义
        for (int i = 0; i < 1000; i++) {
            Math.hypot(Math.pow(92456789, i), Math.cos(i));
        }
        count++;
    }

    public int getCount() {
        return count;
    }
}

위 코드는 다중 스레드 클래스를 정의합니다. run 메서드의 주요 논리는 공유 리소스 수의 자체 증가 작업이며, 또한 count 변수에 휘발성 키를 추가합니다. 단어는 메모리에서 읽고 쓰여야 합니다. 여러 스레드가 실행 중인 경우, 즉 여러 스레드가 count 변수의 자체 증가 작업을 수행하면 count 변수가 더티 데이터를 생성합니까? 생각해 보세요. 우리는 count에 휘발성 키워드를 추가했습니다! 멀티스레딩을 시뮬레이션하는 코드는 다음과 같습니다.

public static void main(String[] args) throws InterruptedException {
        // 理想值,并作为最大循环次数
        int value = 1000;
        // 循环次数,防止造成无限循环或者死循环
        int loops = 0;
        // 主线程组,用于估计活动线程数
        ThreadGroup tg = Thread.currentThread().getThreadGroup();
        while (loops++ < value) {
            // 共享资源清零
            UnsafeThread ut = new UnsafeThread();
            for (int i = 0; i < value; i++) {
                new Thread(ut).start();
            }
            // 先等15毫秒,等待活动线程为1
            do {
                Thread.sleep(15);
            } while (tg.activeCount() != 1);
            // 检查实际值与理论值是否一致
            if (ut.getCount() != value) {
                // 出现线程不安全的情况
                System.out.println("循环到:" + loops + " 遍,出现线程不安全的情况");
                System.out.println("此时,count= " + ut.getCount());
                System.exit(0);
            }
        }

    }

휘발성 변수를 "추악"하게 만들려면 여전히 약간의 노력이 필요합니다. 이 프로그램의 실행 로직은 다음과 같습니다.

100개의 스레드를 시작하고, 공유 리소스 개수의 값을 수정합니다.

15초 동안 일시 중지하고, 활성 스레드의 수가 1인지 관찰합니다(즉, 즉, 메인 스레드만 남음) 실행 중), 1이 아닌 경우 15초 더 기다립니다.

공유 리소스가 안전하지 않은지, 즉 실제 값이 이상적인 값과 같은지 확인합니다. 그렇지 않은 경우 이때 count 값은 더티 데이터입니다.

찾지 못하면 최대 루프에 도달할 때까지 계속 반복합니다.

실행 결과는 다음과 같습니다.

Loop to: 40회, Thread Unsafe 상황 발생
이때 count= 999
이는 가능한 결과일 뿐이며, 시간 실행에 따라 다른 결과가 나타날 수 있습니다. 이는 또한 우리의 count 변수가 데이터 동기화를 구현하지 않는다는 것을 보여줍니다. 여러 스레드에 의해 수정될 때 count의 실제 값은 이론적인 값에서 벗어나며, 이는 휘발성 키워드가 스레드 안전성을 보장할 수 없음을 직접적으로 보여줍니다.
이유를 설명하기 전에 먼저 자체 추가 작업에 대해 이야기하겠습니다. count++는 먼저 count의 값을 빼낸 다음 1을 더하는 것을 의미합니다. 즉, count=count+1입니다. 따라서 다음과 같은 마법 같은 일이 특정 즉각적인 시간 세그먼트에 발생합니다.

(1), 먼저 시간 세그먼트

스레드 A는 휘발성 키워드로 수정되므로 주 메모리에서 최신 count 값을 998로 가져옵니다. 다음은 두 가지 유형으로 나뉩니다. >

싱글 CPU라면 스케줄러는 이때 스레드 A의 실행을 일시정지하고 스레드 B에게 실행 기회를 주기 때문에 스레드 B도 최신 값인 998을 획득하게 된다.

만약 다중 CPU이므로 이때 스레드 A는 계속 실행되고 스레드 B도 최신 값인 998을 얻습니다.

(2), 두 번째 조각

싱글인 경우 CPU, 스레드 B는 +1 작업을 완료합니다(이것은 원자 프로세스입니다). count의 값은 999입니다. 휘발성 변수이므로 주 메모리에 직접 기록됩니다. 그런 다음 A 스레드는 계속 실행되고, 계산된 결과도 999이며, 이는 주 메모리에 다시 기록됩니다.

CPU가 여러 개인 경우 스레드 A는 1을 추가하는 작업을 실행한 후 메인 메모리의 변수 개수를 999로 수정하고, 스레드 B도 완료 후 메인 메모리의 변수 개수를 999로 수정합니다. 처형

这两个时间片段执行完毕后,原本期望的结果为1000,单运行后的值为999,这表示出现了线程不安全的情况。这也是我们要说明的:volatile关键字并不能保证线程安全,它只能保证当前线程需要该变量的值时能够获得最新的值,而不能保证线程修改的安全性。

顺便说一下,在上面的代码中,UnsafeThread类的消耗CPU计算是必须的,其目的是加重线程的负荷,以便出现单个线程抢占整个CPU资源的情景,否则很难模拟出volatile线程不安全的情况,大家可以自行模拟测试。

回到顶部

建议124:异步运算考虑使用Callable接口

  多线程应用有两种实现方式,一种是实现Runnable接口,另一种是继承Thread类,这两个方法都有缺点:run方法没有返回值,不能抛出异常(这两个缺点归根到底是Runnable接口的缺陷,Thread类也实现了Runnable接口),如果需要知道一个线程的运行结果就需要用户自行设计,线程类本身也不能提供返回值和异常。但是从Java1.5开始引入了一个新的接口Callable,它类似于Runnable接口,实现它就可以实现多线程任务,Callable的接口定义如下:

public interface Callable<V> {
    /**
     * Computes a result, or throws an exception if unable to do so.
     *
     * @return computed result
     * @throws Exception if unable to compute a result
     */
    V call() throws Exception;
}

  实现Callable接口的类,只是表明它是一个可调用的任务,并不表示它具有多线程运算能力,还是需要执行器来执行的,我们先编写一个任务类,代码如下: 

//税款计算器
class TaxCalculator implements Callable<Integer> {
    // 本金
    private int seedMoney;

    // 接收主线程提供的参数
    public TaxCalculator(int _seedMoney) {
        seedMoney = _seedMoney;
    }

    @Override
    public Integer call() throws Exception {
        // 复杂计算,运行一次需要2秒
        TimeUnit.MILLISECONDS.sleep(2000);
        return seedMoney / 10;
    }
}

  这里模拟了一个复杂运算:税款计算器,该运算可能要花费10秒钟的时间,此时不能让用户一直等着吧,需要给用户输出点什么,让用户知道系统还在运行,这也是系统友好性的体现:用户输入即有输出,若耗时较长,则显示运算进度。如果我们直接计算,就只有一个main线程,是不可能有友好提示的,如果税金不计算完毕,也不会执行后续动作,所以此时最好的办法就是重启一个线程来运算,让main线程做进度提示,代码如下:

public static void main(String[] args) throws InterruptedException,
            ExecutionException {
        // 生成一个单线程的异步执行器
        ExecutorService es = Executors.newSingleThreadExecutor();
        // 线程执行后的期望值
        Future<Integer> future = es.submit(new TaxCalculator(100));
        while (!future.isDone()) {
            // 还没有运算完成,等待200毫秒
            TimeUnit.MICROSECONDS.sleep(200);
            // 输出进度符号
            System.out.print("*");
        }
        System.out.println("\n计算完成,税金是:" + future.get() + "  元 ");
        es.shutdown();
    }

  在这段代码中,Executors是一个静态工具类,提供了异步执行器的创建能力,如单线程异步执行器newSingleThreadExecutor、固定线程数量的执行器newFixedThreadPool等,一般它是异步计算的入口类。future关注的是线程执行后的结果,比如没有运行完毕,执行结果是多少等。此段代码的运行结果如下所示:

      **********************************************......

      计算完成,税金是:10  元

  执行时,"*"会依次递增,表示系统正在运算,为用户提供了运算进度,此类异步计算的好处是:

尽可能多的占用系统资源,提供快速运算

可以监控线程的执行情况,比如是否执行完毕、是否有返回值、是否有异常等。

可以为用户提供更好的支持,比如例子中的运算进度等。

回到顶部

建议125:优先选择线程池

  在Java1.5之前,实现多线程比较麻烦,需要自己启动线程,并关注同步资源,防止出现线程死锁等问题,在1.5版本之后引入了并行计算框架,大大简化了多线程开发。我们知道一个线程有五个状态:新建状态(NEW)、可运行状态(Runnable,也叫作运行状态)、阻塞状态(Blocked)、等待状态(Waiting)、结束状态(Terminated),线程的状态只能由新建转变为了运行状态后才能被阻塞或等待,最后终结,不可能产生本末倒置的情况,比如把一个结束状态的线程转变为新建状态,则会出现异常,例如如下代码会抛出异常:

public static void main(String[] args) throws InterruptedException {
        // 创建一个线程,新建状态
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程正在运行");
            }
        });
        // 运行状态
        t.start();
        // 是否是运行状态,若不是则等待10毫秒
        while (!t.getState().equals(Thread.State.TERMINATED)) {
            TimeUnit.MICROSECONDS.sleep(10);
        }
        // 直接由结束转变为云心态
        t.start();
    }

  此段程序运行时会报java.lang.IllegalThreadStateException异常,原因就是不能从结束状态直接转变为运行状态,我们知道一个线程的运行时间分为3部分:T1为线程启动时间,T2为线程的运行时间,T3为线程销毁时间,如果一个线程不能被重复使用,每次创建一个线程都需要经过启动、运行、销毁时间,这势必增大系统的响应时间,有没有更好的办法降低线程的运行时间呢?

  T2是无法避免的,只有通过优化代码来实现降低运行时间。T1和T2都可以通过线程池(Thread Pool)来缩减时间,比如在容器(或系统)启动时,创建足够多的线程,当容器(或系统)需要时直接从线程池中获得线程,运算出结果,再把线程返回到线程池中___ExecutorService就是实现了线程池的执行器,我们来看一个示例代码:

public static void main(String[] args) throws InterruptedException {
        // 2个线程的线程池
        ExecutorService es = Executors.newFixedThreadPool(2);
        // 多次执行线程体
        for (int i = 0; i < 4; i++) {
            es.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName());
                }
            });
        }
        // 关闭执行器
        es.shutdown();
    }

  此段代码首先创建了一个包含两个线程的线程池,然后在线程池中多次运行线程体,输出运行时的线程名称,结果如下:

        pool-1-thread-1
        pool-1-thread-2
        pool-1-thread-1
        pool-1-thread-2

   本次代码执行了4遍线程体,按照我们之前阐述的" 一个线程不可能从结束状态转变为可运行状态 ",那为什么此处的2个线程可以反复使用呢?这就是我们要搞清楚的重点。

  线程池涉及以下几个名词:

工作线程(Worker):线程池中的线程,只有两个状态:可运行状态和等待状态,没有任务时它们处于等待状态,运行时它们循环的执行任务。

任务接口(Task):这是每个任务必须实现的接口,以供工作线程调度器调度,它主要规定了任务的入口、任务执行完的场景处理,任务的执行状态等。这里有两种类型的任务:具有返回值(异常)的Callable接口任务和无返回值并兼容旧版本的Runnable接口任务。

任务对列(Work Quene):也叫作工作队列,用于存放等待处理的任务,一般是BlockingQuene的实现类,用来实现任务的排队处理。

  我们首先从线程池的创建说起,Executors.newFixedThreadPool(2)表示创建一个具有两个线程的线程池,源代码如下:

public class Executors {
    //生成一个最大为nThreads的线程池执行器
  public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }

}

  这里使用了LinkedBlockingQueue作为队列任务管理器,所有等待处理的任务都会放在该对列中,需要注意的是,此队列是一个阻塞式的单端队列。线程池建立好了,那就需要线程在其中运行了,线程池中的线程是在submit第一次提交任务时建立的,代码如下:

public Future<?> submit(Runnable task) {
        //检查任务是否为null
        if (task == null) throw new NullPointerException();
        //把Runnable任务包装成具有返回值的任务对象,不过此时并没有执行,只是包装
        RunnableFuture<Object> ftask = newTaskFor(task, null);
        //执行此任务
        execute(ftask);
        //返回任务预期执行结果
        return ftask;
    }

  此处的代码关键是execute方法,它实现了三个职责。

创建足够多的工作线程数,数量不超过最大线程数量,并保持线程处于运行或等待状态。

把等待处理的任务放到任务队列中

从任务队列中取出任务来执行

  其中此处的关键是工作线程的创建,它也是通过new Thread方式创建的一个线程,只是它创建的并不是我们的任务线程(虽然我们的任务实现了Runnable接口,但它只是起了一个标志性的作用),而是经过包装的Worker线程,代码如下:  

private final class Worker implements Runnable {
// 运行一次任务
    private void runTask(Runnable task) {
        /* 这里的task才是我们自定义实现Runnable接口的任务 */
        task.run();
        /* 该方法其它代码略 */
    }
    // 工作线程也是线程,必须实现run方法
    public void run() {
        try {
            Runnable task = firstTask;
            firstTask = null;
            while (task != null || (task = getTask()) != null) {
                runTask(task);
                task = null;
            }
        } finally {
            workerDone(this);
        }
    }
    // 任务队列中获得任务
    Runnable getTask() {
        /* 其它代码略 */
        for (;;) {
            return r = workQueue.take();
        }
    }
}

 此处为示意代码,删除了大量的判断条件和锁资源。execute方法是通过Worker类启动的一个工作线程,执行的是我们的第一个任务,然后改线程通过getTask方法从任务队列中获取任务,之后再继续执行,但问题是任务队列是一个BlockingQuene,是阻塞式的,也就是说如果该队列的元素为0,则保持等待状态,直到有任务进入为止,我们来看LinkedBlockingQuene的take方法,代码如下:  

public E take() throws InterruptedException {
        E x;
        int c = -1;
        final AtomicInteger count = this.count;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lockInterruptibly();
        try {
            try {
                // 如果队列中的元素为0,则等待
                while (count.get() == 0)
                    notEmpty.await();
            } catch (InterruptedException ie) {
                notEmpty.signal(); // propagate to a non-interrupted thread
                throw ie;
            }
            // 等待状态结束,弹出头元素
            x = extract();
            c = count.getAndDecrement();
            // 如果队列数量还多于一个,唤醒其它线程
            if (c > 1)
                notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
        if (c == capacity)
            signalNotFull();
        // 返回头元素
        return x;
    }

 分析到这里,我们就明白了线程池的创建过程:创建一个阻塞队列以容纳任务,在第一次执行任务时创建做够多的线程(不超过许可线程数),并处理任务,之后每个工作线程自行从任务对列中获得任务,直到任务队列中的任务数量为0为止,此时,线程将处于等待状态,一旦有任务再加入到队列中,即召唤醒工作线程进行处理,实现线程的可复用性。

  使用线程池减少的是线程的创建和销毁时间,这对于多线程应用来说非常有帮助,比如我们常用的Servlet容器,每次请求处理的都是一个线程,如果不采用线程池技术,每次请求都会重新创建一个新的线程,这会导致系统的性能符合加大,响应效率下降,降低了系统的友好性。


성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.