并发编程网Java并发面试题

澳门新葡亰平台游戏网站 1

本文由码农网 – Sandbox
Wang原创翻译,转载请看清文末的转载要求,欢迎参与我们的付费投稿计划!

线程可以充分发挥系统的处理能力,提高资源利用率。同时现有的线程可以提升系统响应性。

部分答案参考了网络上的回答,具体可以参考书籍:
《Java并发编程实战》
《深入理解Java虚拟机》
及JDK源码

1、介绍

本文讨论的重点在于多线程应用程序的性能问题。我们会先给性能和扩展性下一个定义,然后再仔细学习一下Amdahl法则。下面的内容我们会考察一下如何用不同的技术方法来减少锁竞争,以及如何用代码来实现。

但是在安全性与极限性能上,我们首先需要保证的是安全性。

多线程

2、性能

我们都知道,多线程可以用来提高程序的性能,背后的原因在于我们有多核的CPU或多个CPU。每个CPU的内核都可以自己完成任务,因此把一个大的任务分解成一系列的可彼此独立运行的小任务就可以提高程序的整体性能了。可以举个例子,比如有个程序用来将硬盘上某个文件夹下的所有图片的尺寸进行修改,应用多线程技术就可以提高它的性能。使用单线程的方式只能依次遍历所有图片文件并且执行修改,如果我们的CPU有多个核心的话,毫无疑问,它只能利用其中的一个核。使用多线程的方式的话,我们可以让一个生产者线程扫描文件系统把每个图片都添加到一个队列中,然后用多个工作线程来执行这些任务。如果我们的工作线程的数量和CPU总的核心数一样的话,我们就能保证每个CPU核心都有活可干,直到任务被全部执行完成。

对于另外一种需要较多IO等待的程序来说,利用多线程技术也能提高整体性能。假设我们要写这样一个程序,需要抓取某个网站的所有HTML文件,并且将它们存储到本地磁盘上。程序可以从某一个网页开始,然后解析这个网页中所有指向本网站的链接,然后依次抓取这些链接,这样周而复始。因为从我们对远程网站发起请求到接收到所有的网页数据需要等待一段时间,所以我们可以将此任务交给多个线程来执行。让一个或稍微更多一点的线程来解析已经收到的HTML网页以及将找到的链接放入队列中,让其他所有的线程负责请求获取页面。与上一个例子不同的是,在这个例子中,你即便使用多于CPU核心数量的线程也仍然能够获得性能提升。

上面这两个例子告诉我们,高性能就是在短的时间窗口内做尽量多的事情。这个当然是对性能一词的最经典解释了。但是同时,使用线程也能很好地提升我们程序的响应速度。想象我们有这样一个图形界面的应用程序,上方有一个输入框,输入框下面有一个名字叫“处理”的按钮。当用户按下这个按钮的时候,应用程序需要重新对按钮的状态进行渲染(按钮看起来被按下了,当松开鼠标左键时又恢复原状),并且开始对用户的输入进行处理。如果处理用户输入的这个任务比较耗时的话,单线程的程序就无法继续响应用户其他的输入动作了,比如,来自操作系统传送过来的用户单击鼠标事件或鼠标指针移动事件等等,这些事件的响应需要有独立的线程来响应。

可扩展性(Scalability)的意思是程序具备这样的能力:通过添加计算资源就可以获得更高的性能。想象我们需要调整很多图片的大小,因为我们机器的CPU核心数是有限的,所以增加线程数量并不总能相应提高性能。相反,因为调度器需要负责更多线程的创建和关闭,也会占用CPU资源,反而有可能降低性能。

11.1 对性能的思考

提升性能=用更少的资源做更多的事情(太对了,这才是问题的本质)。

资源包括:CPU时钟周期,内存,网络带宽,I/O带宽,数据请求,磁盘空间等。

资源密集型说的就是对上述维度敏感的应用。

与单线程相比,多线程总会一起一些额外的性能开销:

  • 线程协调with coordinating between threads (locking, signaling, and
    memory synchronization)
  • 上下文切换increased context switching
  • 线程创建和销毁thread creation and teardown
  • 线程调度scheduling overhead

可伸缩性是指:增加资源,程序的吞吐可以成比例的增加。

性能的提高往往是一个权衡的过程,需要考虑诸多因素。

1. java中有几种方法可以实现一个线程?

Java中线程的运行最终都是通过Thread类,具体实现上可以有三种方式:
1.直接继承Thread类,并重写run方法
澳门新葡亰平台游戏网站 ,2.实现Runable接口,并通过Thread来调用
3.使用线程池ThreadPoolExecutor,直接向线程池提交任务
实际仍然构建了Runable或Callabe接口并提交到线程池的Thread来运行,因为Java只能单继承,在继承Thread后就不能继承其他类了,而实现Runnable接口后还可以实现其它接口,继承类,比较来说更灵活

2.1 Amdahl法则

上一段提到了在某些情形下,添加额外的运算资源可以提高程序的整体性能。为了能够计算出当我们添加了额外的资源的时候到底能获得多少性能提升,我们有必要来检查一下程序有哪些部分是串行运行(或同步运行),有哪些部分是并行运行的。如果我们把需要同步执行的代码占比量化为B(例如,需要同步执行的代码的行数),把CPU的总核心数记为n,那么,根据Amdahl法则,我们可以获得的性能提升的上限是:

澳门新葡亰平台游戏网站 1

如果n趋于无穷大的话,(1-B)/n就收敛于0。因此,我们可以忽略这个表达式的值,因此性能提升位数收敛于1/B,这里面的B代表是那些必须同步运行的代码比例。如果B等于0.5的话,那意味着程序的一半代码无法并行运行,0.5的倒数是2,因此,即使我们添加无数个CPU核心,我们获得的性能提升也最多是2倍。假设我们现在把程序修改了一下,修改之后只有0.25的代码必须同步运行,现在1/0.25=4,表示我们的程序如果在具有大量CPU的硬件上运行时速度将会比在单核的硬件上快大概4倍。

另一方面,通过Amdahl法则,我们也能根据我们想获得的提速的目标计算出程序应该的同步代码的比例。如果我们想要达到100倍的提速,而1/100=0.01,意味着,我们程序同步执行的代码的数量最多不能超过1%。

总结Amdahl法则我们可以看出,我们通过添加额外CPU来获得性能提升的最大值取决于程序同步执行部分代码所占的比例有多小。虽然在实际中,想要计算出这个比例并不总是那么容易,更别说面对一些大型的商业系统应用了,但是Amdahl法则给了我们很重要的启示,那就是,我们必须非常仔细地去考虑那些必须同步执行的代码,并且力图减少这部分代码。

11.2 Amdahl定律 Amdahl’s Law

收割可以靠并行提高性能,而作物生长则不行。这是一个很简单的自然界的问题,在计算机界也存在,需要对问题进行合理的分解,发现潜在的并行能力。

Amdahl定律:并行计算中的加速比是用并行前的执行速度和并行后的执行速度之比来表示的,它表示了在并行化之后的效率提升情况。

speedup <= 1 / F + (1 – F) /N

F表示被串行化的部分,N表示处理器数量。

如果N无穷大,那么最大的加速比例是1/F。理论上如果50%是串行的,那么最大的加速比只能是2。如果10%串行。那么最大加速比接近10,如果N=10也就是说有10个处理器资源,那么最高的加速比是5.4,在100个处理器的情况下是9.2。

但是任何程序都存在串行部分,例如从队列中take数据,访问数据库的操作等,这是绝对的。

书中举了一个例子是Synchronized
linkedlist和ConcurrentLinkedQueue的吞吐率对比,在处理器数量到达上限后,他们的吞吐都基本是一条持平的线,但是Synchronized
linkedlist吞吐率更低,在处理器较少的情况下就到达了极限,这主要受context
switch的限制。

2. 如何停止一个正在运行的线程?

Java线程虽然提供了stop和suspend方法,但是不推荐使用来停止线程。
stop方法天生不安全,会立刻停止线程的运行,因此会导致数据出现不一致的状态,资源得不到释放等;
suspend方法会阻塞线程,但是不会释放锁持有的锁,从而可能会导致出现死锁。
Thread.interrupt方法可以中断线程执行,并抛出中断异常供上层处理。
比较好的方法是使用标志变量来控制线程的运行,通过标志位来控制线程是继续运行还是挂起或者清理退出。挂起可以使用wait方法,并通过notify或notifyAll来重启

2.2 对性能的影响

文章写到这里,我们已经表明这样一个观点:增加更多的线程可以提高程序的性能和响应速度。但是另一方面,想要取得这些好处却并非轻而易举,也需要付出一些代价。线程的使用对性能的提升也会有所影响。

首先,第一个影响来自线程创建的时候。线程的创建过程中,JVM需要从底层操作系统申请相应的资源,并且在调度器中初始化数据结构,以便决定执行线程的顺序。

如果你的线程的数量和CPU的核心数量一样的话,每个线程都会运行在一个核心上,这样或许他们就不会经常被打断了。但是事实上,在你的程序运行的时候,操作系统也会有些自己的运算需要CPU去处理。所以,即使这种情形下,你的线程也会被打断并且等待操作系统来重新恢复它的运行。当你的线程数量超过CPU的核心数量的时候,情况有可能变得更坏。在这种情况下,JVM的进程调度器会打断某些线程以便让其他线程执行,线程切换的时候,刚才正在运行的线程的当前状态需要被保存下来,以便等下次运行的时候可以恢复数据状态。不仅如此,调度器也会对它自己内部的数据结构进行更新,而这也需要消耗CPU周期。所有这些都意味着,线程之间的上下文切换会消耗CPU计算资源,因此带来相比单线程情况下没有的性能开销。

多线程程序所带来的另外一个开销来自对共享数据的同步访问保护。我们可以使用synchronized关键字来进行同步保护,也可以使用Volatile关键字来在多个线程之间共享数据。如果多于一个线程想要去访问某一个共享数据结构的话,就发生了争用的情形,这时,JVM需要决定哪个进程先,哪个进程后。如果决定该要执行的线程不是当前正在运行的线程,那么就会发生线程切换。当前线程需要等待,直到它成功获得了锁对象。JVM可以自己决定如何来执行这种“等待”,假如JVM预计离成功获得锁对象的时间比较短,那JVM可以使用激进等待方法,比如,不停地尝试获得锁对象,直到成功,在这种情况下这种方式可能会更高效,因为比较进程上下文切换来说,还是这种方式更快速一些。把一个等待状态的线程挪回到执行队列也会带来额外的开销。

因此,我们要尽力避免由于锁竞争而带来的上下文切换。下面一节将阐述两种降低这种竞争发生的方法。

11.3 线程引入的开销

单线程不存在线程调度,也不存在同步开销,不需要使用锁来保证安全一致性。而多线程这些都需要考虑。

3. notify()和notifyAll()有什么区别?

当一个线程进入wait状态后,必需等待其它线程通过notify或notifyAll唤醒。
notify方法仅会唤醒在等待对象上的一个线程并获得锁,notifyAll则会唤醒所有等待对象上的线程重新竞争锁。
使用notify需要注意唤醒的为随机一个线程,执行后如果没有再唤醒则可能会导致死锁;
而notifyAll等待线程都已经被唤醒,重新等待获取锁,不会出现死锁情况。

2.3 锁竞争

像上一节所说的那样,两个或更多线程对锁的竞争访问会带来额外的运算开销,因为竞争的发生逼迫调度器来让一个线程进入激进等待状态,或者让它进行等待状态而引发两次上下文切换。有某些情况下,锁竞争的恶果可以通过以下方法来减轻:

1,减少锁的作用域;

2,减少需要获取锁的频率;

3,尽量使用由硬件支持的乐观锁操作,而不是synchronized;

4,尽量少用synchronized;

5,减少使用对象缓存

11.3.1 上下文切换

操作系统的设计者巧妙地利用了时间片轮转的方式,
CPU给每个任务都服务一定的时间, 然后把当前任务的状态保存下来,
在加载下一任务的状态后, 继续服务下一任务.
如果可运行的线程数大于CPU数量,那么OS会最终将某个正在运行的线程调度出来,从而让其他线程能够使用CPU,这会导致一次上下文切换,主要包括当前线程“保存现场”,并且新调度出来的线程需要“恢复现场“。这里的context
switch直接消耗包括: CPU寄存器需要保存和加载, 系统调度器的代码需要执行,
TLB实例需要重新加载, CPU 的pipeline需要刷掉;
间接消耗指的是多核的cache之间得共享数据,
间接消耗对于程序的影响要看线程工作区操作数据的大小).

JVM和OS消耗的CPU时钟周期越少,那么APP可用的CPU时钟周期就越多。

往往OS有一个最小的执行时间,防止过于频繁的上下文切换。

JVM会因为阻塞比如锁、阻塞I/O而挂起线程,如果频繁的阻塞,就会无法使用完整的调度时间片。//?

如果可运行的线程数大于CPU的内核数,那么OS会根据一定的调度算法,强行切换正在运行的线程,从而使其它线程能够使用CPU周期。

切换线程会导致上下文切换。线程的调度会导致CPU需要在操作系统和进程间花费更多的时间片段,这样真正执行应用程序的时间就减少了。另外上下文切换也会导致缓存的频繁进出,对于一个刚被切换的线程来说,可能由于高速缓冲中没有数据而变得更慢,从而导致更多的IO开销。

vmstat 命令可以看cs这一个字段看上下文切换的数据。

4. sleep()和 wait()有什么区别?

两个方法来自不同的类,sleep方法属于Thread类,wait方法属于Object类。
sleep为静态方法,会让当前线程进入休眠,而wait为成员方法,会让调用线程进入等待。
sleep必需传入一个时间,让当前线程进入休眠状态,而wait可以指定也可以不指定等待的时间,线程进入WAIT状态。
虽然两个方法都可以暂停线程的运行,但sleep不会释放所持有的锁,线程恢复后可直接运行;wait则会释放持有的锁,在恢复后需要重新竞争获取锁。

2.3.1 缩减同步域

如果代码持有锁超过必要的时间,那么可以应用这第一种方法。通常我们可以将一行或多行代码移出同步区域来降低当前线程持有锁的时间。在同步区域里运行的代码数量越少,当前线程就会越早地释放锁,从而让其他线程更早地获得锁。这与Amdahl法则相一致的,因为这样做减少了需要同步执行的代码量。

为了更好地理解,看下面的源码:

public class ReduceLockDuration implements Runnable {
    private static final int NUMBER_OF_THREADS = 5;
    private static final Map<String, Integer> map = new HashMap<String, Integer>();

    public void run() {
        for (int i = 0; i < 10000; i++) {
            synchronized (map) {
                UUID randomUUID = UUID.randomUUID();
                Integer value = Integer.valueOf(42);
                String key = randomUUID.toString();
                map.put(key, value);
            }
            Thread.yield();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[NUMBER_OF_THREADS];
        for (int i = 0; i < NUMBER_OF_THREADS; i++) {
            threads[i] = new Thread(new ReduceLockDuration());
        }
        long startMillis = System.currentTimeMillis();
        for (int i = 0; i < NUMBER_OF_THREADS; i++) {
            threads[i].start();
        }
        for (int i = 0; i < NUMBER_OF_THREADS; i++) {
            threads[i].join();
        }
        System.out.println((System.currentTimeMillis()-startMillis)+"ms");
    }
}

在上面的例子中,我们让五个线程来竞争访问共享的Map实例,为了在同一时刻只有一个线程可以访问到Map实例,我们将向Map中添加Key/Value的操作放到了synchronized保护的代码块中。当我们仔细察看这段代码的时候,我们可以看到,计算key和value的几句代码并不需要同步执行,key和value只属于当前执行这段代码的线程,仅仅对当前线程有意义,并且不会被其他线程所修改。因此,我们可以把这几句移出同步保护。如下:

public void run() {
    for (int i = 0; i < 10000; i++) {
        UUID randomUUID = UUID.randomUUID();
        Integer value = Integer.valueOf(42);
        String key = randomUUID.toString();
        synchronized (map) {
            map.put(key, value);
        }
        Thread.yield();
    }
}

降低同步代码所带来的效果是可以测量的。在我的机器上,整个程序的执行时间从420ms降低到了370ms。看看吧,仅仅把三行代码移出同步保护块就可以将程序运行时间减少11%。Thread.yield()这句代码是为了诱发线程上下文切换的,因为这句代码会告诉JVM当前线程想要交出当前使用的计算资源,以便让其他等待运行的线程运行。这样也会带来更多的锁竞争的发生,因为,如果不如此的话某一个线程就会更久地占用某个核心继而减少了线程上下文切换。

11.3.2 内存同步

同步的性能开销包括多个方面。在synchronized和volatile提供的可见性保证中会使用一些特殊指令,即内存栅栏(memory
barrier),内存栅栏可以刷新缓存,满足可见性,但是它也会抑制一些编译器优化,例如不能指令重排序。

现代的JVM对于无竞争的synchronized的消耗非常小,基本微乎其微。

同时现代的JVM编译优化做的非常成熟,一些不必要的同步开销往往可以优化掉。例如,下面的代码会去掉锁获取。

synchronized (new Object()) {
 // do something
} 

还有一些比如escape
analysis会找出不会发布到堆上的本地对象,锁的获取和释放会被优化为最小的次数甚至去掉。例如下面的操作。

public String getStoogeNames() {
 List<String> stooges = new Vector<String>();
 stooges.add("Moe");
 stooges.add("Larry");
 stooges.add("Curly");
 return stooges.toString();
} 

当然即使不escape,也会有lock
coarsening过程,将临近的同步代码块使用同一个锁合并起来。这都减少了同步的开销。

所以不必过度担心非竞争同步带来的开销,这个基本的机制已经非常的快了,而且JVM还有能进行额外的优化以进一步降低或者消除开销的本领。

不同线程间要进行数据同步,synchronized以及volatile提供的可见性都会导致缓存失效。线程栈之间的数据要和主存进行同步,这些同步有一些小小的开销。如果线程间同时要进行数据同步,那么这些同步的线程可能都会受阻。

5. 什么是Daemon线程?它有什么意义?

Daemon线程即后台线程或守护线程,是指在程序运行的时候在后台提供一种通用服务的线程,并且这个线程并不属于程序中不可或缺的部分。因此,当所有的非后台线程结束时,程序也就终止了,同时会杀死进程中的所有后台线程。
反过来说,只要有任何非后台线程还在运行,程序就不会终止。必须在线程启动之前调用setDaemon()方法,才能把它设置为后台线程。注意:后台进程在不执行finally子句的情况下就会终止其run()方法。

2.3.2 分拆锁

另外一种减少锁竞争的方法是将一块被锁定保护的代码分散到多个更小的保护块中。如果你的程序中使用了一个锁来保护多个不同对象的话,这种方式会有用武之地。假设我们想要通过程序来统计一些数据,并且实现了一个简单的计数类来持有多个不同的统计指标,并且分别用一个基本计数变量来表示(long类型)。因为我们的程序是多线程的,所以我们需要对访问这些变量的操作进行同步保护,因为这些操作动作来自不同的线程。要达到这个目的,最简单的方式就是对每个访问了这些变量的函数添加synchronized关键字。

public static class CounterOneLock implements Counter {
    private long customerCount = 0;
    private long shippingCount = 0;

    public synchronized void incrementCustomer() {
        customerCount++;
    }

    public synchronized void incrementShipping() {
        shippingCount++;
    }

    public synchronized long getCustomerCount() {
        return customerCount;
    }

    public synchronized long getShippingCount() {
        return shippingCount;
    }
}

这种方式也就意味着,对这些变量的每次修改都会引发对其他Counter实例的锁定。其他线程如果想要对另外一个不同的变量调用increment方法,那也只能等待前一个线程释放了锁控制之后才能有机会去完成。在此种情况下,对每个不同的变量使用单独的synchronized保护将会提高执行效率。

public static class CounterSeparateLock implements Counter {
    private static final Object customerLock = new Object();
    private static final Object shippingLock = new Object();
    private long customerCount = 0;
    private long shippingCount = 0;

    public void incrementCustomer() {
        synchronized (customerLock) {
            customerCount++;
        }
    }

    public void incrementShipping() {
        synchronized (shippingLock) {
            shippingCount++;
        }
    }

    public long getCustomerCount() {
        synchronized (customerLock) {
            return customerCount;
        }
    }

    public long getShippingCount() {
        synchronized (shippingLock) {
            return shippingCount;
        }
    }
}

这种实现为每个计数指标引入了一个单独synchronized对象,因此,一个线程想要增加Customer计数的时候,它必须等待另一个正在增加Customer计数的线程完成,而并不用等待另一个正在增加Shipping计数的线程完成。

使用下面的类,我们可以非常容易地计算分拆锁所带来的性能提升。

public class LockSplitting implements Runnable {
    private static final int NUMBER_OF_THREADS = 5;
    private Counter counter;

    public interface Counter {
        void incrementCustomer();

        void incrementShipping();

        long getCustomerCount();

        long getShippingCount();
    }

    public static class CounterOneLock implements Counter { ... }

    public static class CounterSeparateLock implements Counter { ... }

    public LockSplitting(Counter counter) {
        this.counter = counter;
    }

    public void run() {
        for (int i = 0; i < 100000; i++) {
            if (ThreadLocalRandom.current().nextBoolean()) {
                counter.incrementCustomer();
            } else {
                counter.incrementShipping();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[NUMBER_OF_THREADS];
        Counter counter = new CounterOneLock();
        for (int i = 0; i < NUMBER_OF_THREADS; i++) {
            threads[i] = new Thread(new LockSplitting(counter));
        }
        long startMillis = System.currentTimeMillis();
        for (int i = 0; i < NUMBER_OF_THREADS; i++) {
            threads[i].start();
        }
        for (int i = 0; i < NUMBER_OF_THREADS; i++) {
            threads[i].join();
        }
        System.out.println((System.currentTimeMillis() - startMillis) + "ms");
    }
}

在我的机器上,单一锁的实现方法平均花费56ms,两个单独锁的实现是38ms。耗时大约降低了大概32%。

另外一种提升方式是,我们甚至可以更进一步地将读写分开用不同的锁来保护。原来的Counter类提供了对计数指标分别提供了读和写的方法,但是事实上,读操作并不需要同步保护,我们可以放心让多个线程并行读取当前指标的数值,同时,写操作必须得到同步保护。java.util.concurrent包里提供了有对ReadWriteLock接口的实现,可以方便地实现这种区分。

ReentrantReadWriteLock实现维护了两个不同的锁,一个保护读操作,一个保护写操作。这两个锁都有获取锁和释放锁的操作。仅仅当在没有人获取读锁的时候,写锁才能成功获得。反过来,只要写锁没有被获取,读锁可以被多个线程同时获取。为了演示这种方法,下面的Counter类使用了ReadWriteLock,如下:

public static class CounterReadWriteLock implements Counter {
    private final ReentrantReadWriteLock customerLock = new ReentrantReadWriteLock();
    private final Lock customerWriteLock = customerLock.writeLock();
    private final Lock customerReadLock = customerLock.readLock();
    private final ReentrantReadWriteLock shippingLock = new ReentrantReadWriteLock();
    private final Lock shippingWriteLock = shippingLock.writeLock();
    private final Lock shippingReadLock = shippingLock.readLock();
    private long customerCount = 0;
    private long shippingCount = 0;

    public void incrementCustomer() {
        customerWriteLock.lock();
        customerCount++;
        customerWriteLock.unlock();
    }

    public void incrementShipping() {
        shippingWriteLock.lock();
        shippingCount++;
        shippingWriteLock.unlock();
    }

    public long getCustomerCount() {
        customerReadLock.lock();
        long count = customerCount;
        customerReadLock.unlock();
        return count;
    }

    public long getShippingCount() {
        shippingReadLock.lock();
        long count = shippingCount;
        shippingReadLock.unlock();
        return count;
    }
}

所有的读操作都被读锁保护,同时,所有的写操作都被写锁所保护。如果程序中执行的读操作要远大于写操作的话,这种实现可以带来比前一节的方式更大的性能提升,因为读操作可以并发进行。

11.3.3 阻塞

竞争的同步需要OS介入,从而增加了开销。当在锁上发生竞争时,失败者线程会被阻塞,JVM在实现发现阻塞的行为时,可以采用

  • 自旋等待 spin-waiting
  • 或者OS挂起被阻塞的线程

这两种的效率高低取决于上下文切换的开销以及成功获取锁之前的等待时间,如果等待时间较短,则spin-waiting,如果较长则挂起。

一个线程被阻塞会产生上下文切换的影响,但是它到底何时执行这是由OS决定的,靠时间分片机制,这个调度的策略是OS解决的,而JVM的scheduler解决的是阻塞释放锁之后哪个线程需要被select出来执行,也就是转到runnable状态。

There is no single Java Virtual Machine; JVM is a specification, and
there are multiple implementations of it, including the OpenJDK version
and the Sun version of it, among others. I don’t know for certain, but I
would guess that any reasonable JVM would simply use the underlying
threading mechanism provided by the OS, which would imply POSIX Threads
(pthreads) on UNIX (Mac OS X, Linux, etc.) and would imply WIN32 threads
on Windows. Typically, those systems use a round-robin strategy by
default. Many types of algorithms exist like preemptive and time
slicing
with round robin etc.

The JVM is based on preemptive and priority based scheduling
algorithm to select thread to run.

每个Java线程一对一映射到Solaris平台上的一个本地线程上,并将线程调度交由本地线程的调度程序。由于Java线程是与本地线程是一对一地绑在一起的,所以改变Java线程的优先权也不会有可靠地运行结果。

对于类Unix系统而言,一般都是进程作为任务的调度单位,也即是操作系统调度器,只会针对进程来分配CPU等资源。由于进程彼此独立,相互不可进行直接访问,这增加了应用的通信成本。所以后面有了微进程,微进程与进程不同的是,允许一定程度上,彼此可以直接进行访问,详细可参考LinuxThreads。JVM在一些类Unix平台下,就是将线程映射到操作系统的微进程,来实现线程调度。这样多线程能够直接被系统调度器进行调度,与此对应的就是其线程的创建和销毁的成本就比较高,而且JVM的线程优先级很难进行匹配,无法提供确切的保证,仅仅是个hint。

当发生锁竞争时,失败的线程会导致阻塞。通常阻塞的线程可能在JVM内部进行自旋等待,或者被操作系统挂起。自旋等待可能会导致更多的CPU切片浪费,而操作系统挂起则会导致更多的上下文切换。

6. java如何实现多线程之间的通讯和协作?

利用同步和互斥来解决多线程之间的通讯和协作;可以说资源互斥、协调竞争是要解决的因,而同步是竞争协调的果。
通过synchronized/wait/notify/notifyAll/Lock/Condition来控制对共享资源的互斥访问,线程等待和唤醒。
利用提供的同步工具如:CyclicBarrier/Semaphore/Countdownbatch。

2.3.3 分离锁

上面一个例子展示了如何将一个单独的锁分开为多个单独的锁,这样使得各线程仅仅获得他们将要修改的对象的锁就可以了。但是另一方面,这种方式也增加了程序的复杂度,如果实现不恰当的话也可能造成死锁。

分离锁是与分拆锁类似的一种方法,但是分拆锁是增加锁来保护不同的代码片段或对象,而分离锁是使用不同的锁来保护不同范围的数值。JDK的java.util.concurrent包里的ConcurrentHashMap即使用了这种思想来提高那些严重依赖HashMap的程序的性能。在实现上,ConcurrentHashMap内部使用了16个不同的锁,而不是封装一个同步保护的HashMap。16个锁每一个负责保护其中16分之一的桶位(bucket)的同步访问。这样一来,不同的线程想要向不同的段插入键的时候,相应的操作会受到不同的锁来保护。但是反过来也会带来一些不好的问题,比如,某些操作的完成现在需要获取多个锁而不是一个锁。如果你想要复制整个Map的话,这16个锁都需要获得才能完成。

11.4 减少锁的竞争

减少锁的竞争能够提高性能和可伸缩性。

在并发程序中,对可伸缩性的最主要的威胁就是独占方式的资源锁。

有三种方式可以减低锁的竞争程度:

  • 减少锁的持有时间
  • 降低锁的请求频率
  • 使用带有协调机制的独占锁,这些机器允许更好的并发性。//?

2.3.4 原子操作

另外一种减少锁竞争的方法是使用原子操作,这种方式会在其他文章中详细阐述原理。java.util.concurrent包对一些常用基础数据类型提供了原子操作封装的类。原子操作类的实现基于处理器提供的“比较置换”功能(CAS),CAS操作只在当前寄存器的值跟操作提供的旧的值一样的时候才会执行更新操作。

这个原理可以用来以乐观的方式来增加一个变量的值。如果我们的线程知道当前的值的话,就会尝试使用CAS操作来执行增加操作。如果期间别的线程已经修改了变量的值,那么线程提供的所谓的当前值已经跟真实的值不一样了,这时JVM来尝试重新获得当前值,并且再尝试一次,反反复复直到成功为止。虽然循环操作会浪费一些CPU周期,但是这样做的好处是,我们不需要任何形式的同步控制。

下面的Counter类的实现就利用了原子操作的方式,你可以看到,并没有使用任何synchronized的代码。

public static class CounterAtomic implements Counter {
    private AtomicLong customerCount = new AtomicLong();
    private AtomicLong shippingCount = new AtomicLong();

    public void incrementCustomer() {
        customerCount.incrementAndGet();
    }

    public void incrementShipping() {
        shippingCount.incrementAndGet();
    }

    public long getCustomerCount() {
        return customerCount.get();
    }

    public long getShippingCount() {
        return shippingCount.get();
    }
}

与CounterSeparateLock类相比,平均运行时间从39ms降低到了16ms,大约降低了58%。

11.4.1 缩小锁的范围(快进快出)

原理就是Amdah定律,串行的代码总量减少了。

1. 什么是可重入锁(ReentrantLock)?

当一个线程请求其它线程持有的锁时会被阻塞,可重入是指一个线程在请求获取自身占有的锁时可以成功,即对同一个锁保护的区域可以重复进入。
JVM内部提供一个针对该锁的线程重入计数,重入时加一,释放时减一。
JDK提供的synchronized和ReentrantLock都是可重入的。

2.3.5 避免热点代码段

一个典型的LIST实现通过会在内容维护一个变量来记录LIST自身所包含的元素个数,每一次从列表里删除或增加元素的时候,这个变量的值都会改变。如果LIST在单线程应用中使用的话,这种方式无可厚非,每次调用size()时直接返回上一次计算之后的数值就行了。如果LIST内部不维护这个计数变量的话,每次调用size()操作都会引发LIST重新遍历计算元素个数。

这种很多数据结构都使用了的优化方式,当到了多线程环境下时却会成为一个问题。假设我们在多个线程之间共享一个LIST,多个线程同时地去向LIST里面增加或删除元素,同时去查询大的长度。这时,LIST内部的计数变量成为一个共享资源,因此所有对它的访问都必须进行同步处理。因此,计数变量成为整个LIST实现中的一个热点。

下面的代码片段展示了这个问题:

public static class CarRepositoryWithCounter implements CarRepository {
    private Map<String, Car> cars = new HashMap<String, Car>();
    private Map<String, Car> trucks = new HashMap<String, Car>();
    private Object carCountSync = new Object();
    private int carCount = 0;

    public void addCar(Car car) {
        if (car.getLicencePlate().startsWith("C")) {
            synchronized (cars) {
                Car foundCar = cars.get(car.getLicencePlate());
                if (foundCar == null) {
                    cars.put(car.getLicencePlate(), car);
                    synchronized (carCountSync) {
                        carCount++;
                    }
                }
            }
        } else {
            synchronized (trucks) {
                Car foundCar = trucks.get(car.getLicencePlate());
                if (foundCar == null) {
                    trucks.put(car.getLicencePlate(), car);
                    synchronized (carCountSync) {
                        carCount++;
                    }
                }
            }
        }
    }

    public int getCarCount() {
        synchronized (carCountSync) {
            return carCount;
        }
    }
}

上面这个CarRepository的实现内部有两个LIST变量,一个用来放洗车元素,一个用来放卡车元素,同时,提供了查询这两个LIST总共的大小的方法。采用的优化方式是,每次添加一个Car元素的时候,都会增加内部的计数变量的值,同时增加的操作受synchronized保护,返回计数值的方法也是一样。

为了避免带来这种额外的代码同步开销,看下面另外一种CarRepository的实现:它不再使用一个内部的计数变量,而是在返回汽车总数的方法里实时计数这个数值。如下:

public static class CarRepositoryWithoutCounter implements CarRepository {
    private Map<String, Car> cars = new HashMap<String, Car>();
    private Map<String, Car> trucks = new HashMap<String, Car>();

    public void addCar(Car car) {
        if (car.getLicencePlate().startsWith("C")) {
            synchronized (cars) {
                Car foundCar = cars.get(car.getLicencePlate());
                if (foundCar == null) {
                    cars.put(car.getLicencePlate(), car);
                }
            }
        } else {
            synchronized (trucks) {
                Car foundCar = trucks.get(car.getLicencePlate());
                if (foundCar == null) {
                    trucks.put(car.getLicencePlate(), car);
                }
            }
        }
    }

    public int getCarCount() {
        synchronized (cars) {
            synchronized (trucks) {
                return cars.size() + trucks.size();
            }
        }
    }
}

现在,仅仅在getCarCount()方法里,两个LIST的访问需要同步保护,像上一种实现那样每次添加新元素时的同步开销已经不存在了。

11.4.2 减小锁的粒度

这种方式就是降低线程请求锁的频率,通过锁分解来实现。

下面的应用明显锁的粒度太粗了。

public class ServerStatusBeforeSplit {
    @GuardedBy("this") public final Set<String> users;
    @GuardedBy("this") public final Set<String> queries;

    public ServerStatusBeforeSplit() {
        users = new HashSet<String>();
        queries = new HashSet<String>();
    }

    public synchronized void addUser(String u) {
        users.add(u);
    }

    public synchronized void addQuery(String q) {
        queries.add(q);
    }

    public synchronized void removeUser(String u) {
        users.remove(u);
    }

    public synchronized void removeQuery(String q) {
        queries.remove(q);
    }
}

锁分解就是独立的变量独立分配锁,不适用全局锁。优化后如下:

public class ServerStatusAfterSplit {
    @GuardedBy("users") public final Set<String> users;
    @GuardedBy("queries") public final Set<String> queries;

    public ServerStatusAfterSplit() {
        users = new HashSet<String>();
        queries = new HashSet<String>();
    }

    public void addUser(String u) {
        synchronized (users) {
            users.add(u);
        }
    }

    public void addQuery(String q) {
        synchronized (queries) {
            queries.add(q);
        }
    }

    public void removeUser(String u) {
        synchronized (users) {
            users.remove(u);
        }
    }

    public void removeQuery(String q) {
        synchronized (users) {
            queries.remove(q);
        }
    }
}

2. 当一个线程进入某个对象的一个synchronized的实例方法后,其它线程是否可进入此对象的其它方法?

其它线程是否可进入对象的其它方法取决与其它方法是否需要请求与改方法相同的锁,一般来说可以进入非synchronized方法。

2.3.6 避免对象缓存复用

在JAVA VM的第一版里,使用new关键字来创建新对象的开销比较大,因此,很多开发人员习惯了使用对象复用模式。为了避免一次又一次重复创建对象,开发人员维护一个缓冲池,每次创建完对象实例之后可以把它们保存在缓冲池里,下次其他线程再需要使用的时候就可以直接从缓冲池里去取。

乍一看,这种方式是很合理的,但是这种模式在多线程应用程序里会出现问题。因为对象的缓冲池在多个线程之间共享,因此所有线程在访问其中的对象时的操作需要同步保护。而这种同步所带来的开销已经大过了创建对象本身了。当然了,创建过多的对象会加重垃圾回收的负担,但是即便把这个考虑在内,避免同步代码所带来的性能提升仍然要好过使用对象缓存池的方式。

本文所讲述的这些优化方案再一次的表明,每一种可能的优化方式在真正应用的时候一定需要多多仔细评测。不成熟的优化方案表面看起来好像很有道理,但是事实上很有可能会反过来成为性能的瓶颈。

11.4.3 锁分段

最典型的例子就是ConcurrentHashMap。

public class StripedMap {
    // Synchronization policy: buckets[n] guarded by locks[n%N_LOCKS]
    private static final int N_LOCKS = 16;
    private final Node[] buckets;
    private final Object[] locks;

    private static class Node {
        Node next;
        Object key;
        Object value;
    }

    public StripedMap(int numBuckets) {
        buckets = new Node[numBuckets];
        locks = new Object[N_LOCKS];
        for (int i = 0; i < N_LOCKS; i++)
            locks[i] = new Object();
    }

    private final int hash(Object key) {
        return Math.abs(key.hashCode() % buckets.length);
    }

    public Object get(Object key) {
        int hash = hash(key);
        synchronized (locks[hash % N_LOCKS]) {
            for (Node m = buckets[hash]; m != null; m = m.next)
                if (m.key.equals(key))
                    return m.value;
        }
        return null;
    }

    public void clear() {
        for (int i = 0; i < buckets.length; i++) {
            synchronized (locks[i % N_LOCKS]) {
                buckets[i] = null;
            }
        }
    }
}

3. synchronized和java.util.concurrent.locks.Lock的异同?

Lock几乎可以实现synchronized所有功能并额外提供其它高级功能,如:定时锁,可中断锁等。
synchronized为Java语言关键字,由JVM内部提供对同步的支持,性能可以随JVM优化而自动提高。Lock则为Java并发包中提供的一个锁实现接口。
synchronized获取的锁随着同步块自动释放,而Lock获取的锁需要在finnaly块中保证释放,否则可能造成死锁。

11.4.4 避免热点域hot field

比如HashMap的size方法,ConcurrentHashMap采用了牺牲size的准确性的策略。

4. 乐观锁和悲观锁的理解及如何实现,有哪些实现方式?

乐观和悲观是指对多并发时是否会出现锁竞争的态度。
乐观锁总是假设并发不会出现竞争来运行程序,不需要先获取锁,当出现竞争且失败时可进行重试。悲观锁则假设一定会出现竞争,需要先获取锁程序才能继续运行。
乐观锁在中低并发和多读少写的情况下可获得更高性能,因为不需要获取锁并且出现冲突的可能性低;
而在高并发情况下出现竞争冲突的概率变高,使用悲观锁可获得更好的性能。
乐观锁的实现:通常使用volatile+CAS机制实现,设计上使用版本号或时间戳进行冲突检测。
悲观锁实现主要有:数据库行锁、表锁,读写锁等

11.4.5 一些替代独占锁的方法

ReadWriteLock,AtomicInteger,UNSAFE.compareAndSwap(..)

并发框架

11.4.6 监测CPU的利用率

vmstat,kill -3 pid

”waiting to lock monitor…“有这句就证明竞争太激烈了。

1. SynchronizedMap和ConcurrentHashMap有什么区别?

SynchronizedMap是针对HashMap的同步包装器,通过对读写方法加synchronized内部锁来实现同步。会对整个对象同步,读写操作都需要加锁,相当于只能顺序执行;迭代时修改会抛出异常
ConcurrentHashMap为JDK并发包提供的高并发HashMap实现,可支持多并发读和一定量并发写。内部读操作不加锁,写操作通过CAS机制和对细粒度的桶加锁代替对整个对象加锁,因此可以获得更好的性能。支持迭代时另一线程尝试修改而不抛出异常。

11.5 示例:比较Map的性能

比较了ConcurrentHashMap和synchronized hashmap的性能对比。

串行访问Map一个锁 pk 多个线程能并发的访问Map通过分段锁。

竞争非常激烈的时候,synchronized
hashmap伸缩性非常差,吞吐量不会随着线程数增加而增加,反而降低,因为每个操作消耗的时间大部分都用于上下文切换和调度延迟上了。

2. CopyOnWriteArrayList可以用于什么应用场景?

适用于多读少写的场景。CopyOnWriteArrayList内部实现对读操作不加锁,而对写操作会首先通过数组复制获得当前对象的一份拷贝供修改,修改结束后通过CAS操作更新老的引用指向修改后的拷贝。但需要注意数组太大造成的数组复制性能降低。

11.6 减少上下文切换的开销

举个例子,就是APP记录日志,例如写日志到本地或者远程RPC,直接记录会存在I/O阻塞,靠一个轻量级的queue来解耦,使得APP不感知影响,减少阻塞。

http://www.artima.com/insidejvm/ed2/threadsynch.html
//TODO

线程安全

总结

了解了性能的提升的几个方面,也了解性能的开销后,应用程序就要根据实际的场景进行取舍和评估。没有一劳永逸的优化方案,不断的进行小范围改进和调整是提高性能的有效手段。当前一些大的架构调整也会导致较大的性能的提升。

性能提升考虑的方面:

  • 系统平台的资源利用率

一个程序对系统平台的资源利用率是指某一个设备繁忙且服务于此程序的时间占所有时间的比率。从物理学的角度讲类似于有用功的比率。简单的说就是:资源利用率=有效繁忙时间/总耗费时间。

也就说尽可能的让设备做有用的功,同时榨取其最大值。无用的循环可能会导致CPU
100%的使用率,但不一定是有效的工作。有效性通常难以衡量,通常只能以主观来评估,或者通过被优化的程序的行为来判断是否提高了有效性。

  • 延迟

延迟描述的是完成任务所耗费的时间。延迟有时候也成为响应时间。如果有多个并行的操作,那么延迟取决于耗费时间最大的任务。

  • 多处理

多处理是指在单一系统上同时执行多个进程或者多个程序的能力。多处理能力的好处是可以提高吞吐量。多处理可以有效利用多核CPU的资源。

  • 多线程

多线程描述的是同一个地址空间内同时执行多个线程的过程。这些线程都有不同的执行路径和不同的栈结构。我们说的并发性更多的是指针对线程。

  • 并发性

同时执行多个程序或者任务称之为并发。单程序内的多任务处理或者多程序间的多任务处理都认为是并发。

  • 吞吐量

吞吐量衡量系统在单位之间内可以完成的工作总量。对于硬件系统而言,吞吐量是物理介质的上限。在没有达到物理介质之前,提高系统的吞吐量也可以大幅度改进性能。同时吞吐量也是衡量性能的一个指标。

  • 瓶颈

程序运行过程中性能最差的地方。通常而言,串行的IO、磁盘IO、内存单元分配、网络IO等都可能造成瓶颈。某些使用太频繁的算法也有可能成为瓶颈。

  • 可扩展性

这里的可扩展性主要是指程序或系统通过增加可使用的资源而增加性能的能力。

1. 什么叫线程安全?servlet是线程安全吗?

简单来说,在多线程并发调用情况下,行为仍然表现正常。
引用《Java并发编程实战》:

当多个线程访问一个类时,如果不考虑这些线程在运行环境下的调度和交替执行,并且不需要额外的同步及在
调用方代码不需要做其它协调,这个类的行为仍然是正确的,那么称这个类是线程安全的。

servlet不是线程安全的。servlet在运行时是单例模式因此对同一个servlet的多个并发请求会由同一个servlet实例处理。
如果servlet中定义了共享的变量而不采用同步保证则会出现并发问题。

2. 同步有几种实现方法?

1.使用synchronized同步方法;2.使用synchronized同步代码块;3.使用volatile关键字修饰特殊变量;
4.使用ReentrantLock可重入锁 5.使用ThreadLocal线程绑定变量

3. volatile有什么用?能否用一句话说明下volatile的应用场景?

volatile可以保证变量的可见性,即任何线程对变量的修改其它线程可立刻看到,JVM内强制刷新工作内存值;
另外volatile在JVM形成内存屏障,禁止指令重排序。
代码编译后形成一个寄存器加0空操作,使得CPU缓存刷新回内存同时使其它CPU缓存无效化需要重新加载,实现修改立即可见。而修改写回内存代表前面的操作已完成,使得指令重排不能越过。
volatile适用于作为共享标志位变量,对变量值修改不依赖于原值的场景。

4. 请说明下java的内存模型及其工作流程。

Java内存模型即JMM是JVM规范定义用来屏蔽操作系统底层硬件差异的内存设计模型。
JMM主要目标是定义程序中共享变量的访问规则,规定所有共享变量存储在主内存,每个线程又有自己的工作内存,工作内存保存了主内存中本线程用到变量的副本。线程对变量的操作只能在工作内存进行,不能直接操作主内存,线程间工作内存也不能相互访问。
JMM中定义了几种对主内存和工作内存的操作及执行规范来进行主内存和工作内存数据的交互。线程在对一个变量使用use前必需要先经过对主内存的读取read,并加载load到工作内存,而在修改后写回主内存也要先进过对工作内存变量进行存储store然后写入write主内存。

5. 为什么代码会重排序?

代码重排序是为了提供程序在运行时的性能,在Java程序中会出现编译期代码重排序、JVM执行时重排序和CPU指令执行重排序。使用synchronized关键字、volatile关键字和Lock可以在内存形成屏障来禁止指令重排序。

题目地址

You can leave a response, or trackback from your own site.

Leave a Reply

网站地图xml地图