Java并发编程的艺术之多线程

Java并发编程的艺术之多线程

  1. 多线程一定快吗?

public class ConcurrencyTest {

   
/** 执行次数 */
   
private static final long count = 10000l*10;

   
public static void main(String[] args) throws InterruptedException {
        countTimes(
count);
        countTimes(
count*10);
        countTimes(
count*100);
        countTimes(
count*1000);
        countTimes(
count*10000);
    }

   
private static void countTimes(long count) throws InterruptedException {
        System.
out.println("count次数:"+count);
       
//并发计算
        concurrency(count);
       
//单线程计算
        serial(count);
    }

   
private static void concurrency(long count) throws InterruptedException {
       
long start = System.currentTimeMillis();
        Thread thread =
new Thread(new Runnable() {
           
@Override
           
public void run() {
               
long a = 0;
               
for (long i = 0; i < count; i++) {
                    a ++;
                }
            }
        });
        thread.start();
       
long b = 0;
       
for (long i = 0; i < count; i++) {
            b--;
        }
        thread.join();
       
long time = System.currentTimeMillis() - start;
        System.
out.println("concurrency :" + time + "ms,b=" + b);
    }

   
private static void serial(long count) {
       
long start = System.currentTimeMillis();
       
long a = 0;
       
for (long i = 0; i < count; i++) {
            a +=
1;
        }
       
long b = 0;
       
for (long i = 0; i < count; i++) {
            b--;
        }
       
long time = System.currentTimeMillis() - start;
        System.
out.println("serial:" + time + "ms,b=" + b + ",a=" + a);
    }

}

测试结果发现,当数量小于百万的级别的时候,单线程甚至比多线程执行更快。只有当数量大于百万级的时候,多线程才远远大于单线程。这是为什么?因为线程有创建和上下文切换开销。

1.1 如何减少上下文切换?

减少切换上下文的方法有无锁并发编程,CAS算法,使用最少线程和使用协程。

无锁并发线程:多线程竞争锁的时候会引起上下文切换,所以在多线程处理数据时,可以利用一些方式来避免使用锁,如数据ID%Hash算法取模分段,不同的线程处理不同段的数据。

CAS算法:Java并发包下原子类使用CAS算法来更新数据,无需做上下文切换

使用最少线程:避免创建不需要的线程,使用少量的线程的线程处理任务。但是若创建了很多线程,使用少量的线程就会造成多余的线程处于等待。

协程:在单线程中实现多线程的任务调用,并在单线程里维持多个任务间的切换,如redis。

1.2 死锁

线程t1拿到A锁想获取B锁,线程t2拿到B锁后想获取A锁。两者同时运行时导致死锁的情况。

public class DeadLockDemo {

  

    /** A */

    private static String A = "A";

    /** B */

    private static String B = "B";

  

    public static void main(String[] args) {

        new DeadLockDemo().deadLock();

    }

  

    private void deadLock() {

        Thread t1 = new Thread(new Runnable() {

            @Override

            public void run() {

                synchronized (A) {

                    try {

                        Thread.sleep(2000);

                    } catch (InterruptedException e) {

                        e.printStackTrace();

                    }

                    synchronized (B) {

                        System.out.println("1");

                    }

                }

            }

        });

  

        Thread t2 = new Thread(new Runnable() {

            @Override

            public void run() {

                synchronized (B) {

                    synchronized (A) {

                        System.out.println("2");

                    }

                }

            }

        });

        t1.start();

        t2.start();

    }

  

}

  1. as-if-serial语义与happens-before

as-if-serial

不管怎么重排序(编译器和处理器为了提高并行度),程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

如图所示:不管A与B怎么重排序,但是不能在C之后执行。要么AàBàC,要么BàAàC

A: int a = 1;

B: int b = 2;

C: int c = a+b;

happens-before

表示代码执行的顺序关系,A happens-before B(或B happens-before A),B happens-before C,A happens-before C。

  1. Volatile关键字

volatile是Java提供的轻量级的同步机制,保证了可见性,受限原子性,禁止指令集重排序。

Java 语言包含两种内在的同步机制:同步块(或方法)和 volatile 变量,相比于synchronized(synchronized通常称为重量级锁),volatile更轻量级,因为它不会引起线程上下文的切换和调度。但是volatile 变量的同步性较差(有时它更简单并且开销更低),而且其使用也更容易出错。

3.1 volatile特性:可见性,受限原子性,禁止指令集重排序

可见性

即当一个线程修改了声明volatile关键字的变量,这个变量对于其他将要读取的线程是立即可见的。而普通变量无法做到这点。

受限原子性

对于任意单个volatile变量的读写具有原子性,但是对于volatile++这种复合操作不具有原子性。

             

禁止指令集重排序

声明为volatile的变量临界区代码的执行顺序是禁止进行重排序。

可见性,原子性,重排序解释

     原子性

原子表示不能被进一步分割的最小粒子。而原子操作表示不可被终端的一个或一系列操作。

重排序的种类

       编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

       指令集并行的重排序:现代处理器采用了指令级并行技术来讲多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

       内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

什么是重排序?

重排序指的是编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

代码1重排序后不影响最终结果,而代码2重排序后影响最终结果,所以代码2不允许进行重排序。

为什么要重排序?

JVM编译器或CPU处理器为了优化速度,会对代码指令进行重新排序。(前提是不影响最终的执行结果)

3.2 读写内存屏障

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。

JMM把内存屏障分为4类:

屏障类型

指令示例

说明

LoadLoad Barriers

Load1; LoadLoad;Load2

确保Load1数据的装载要比Load2以及后面的装载指令都要先执行。

StoreStore Barriers

Store1; StoreStore; Store2

确保Store1数据对其他处理器可见(刷新到内存)要比Store2及后面的存储指令都要先执行。

LoadStore Barriers

Load1;LoadStore;Store2

确保Load1数据装载先于Store2及所有后续的存储指令。

StoreLoad Barriers

Store1;StoreLoad; Load2

确保Store1数据对其他处理器变得可见(指刷新到内存)先于Load2及所有后续装载指令的装载。StoreLoad Barriers 会使用该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

3.3 重排序对多线程的影响

初始值:flag = false, a = 0.

若操作1与操作2做了重排序,线程执行时,线程A首先将flag=true,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a做计算。但此时操作2并没有被线程A执行。所以在这里多线程程序的语义就被重排序破坏了。

3.4 线程之间的通信图

       主内存中x=0,本地内存A与本地内存B复制主内存x=0为副本。线程A更新x=1,并存储在本地内存A中。若线程A想与线程B进行通信,那么将x=1的值刷新到主内存中,线程B再从主内存去读x更新后的值。

       这个流程就是线程A与线程B在进行共享变量通信,通信过程必须经过主内存。

3.5缓存行(Cache Line)

       Cpu会以缓存行的形式读取主内存中数据,缓存行的大小为2的幂次数字节,

一般的情况下是为64个字节。

如果该变量共享到同一个缓存行,就会影响到整理性能。

如:线程A修改了long类型变量a,但是缓存行中还存在b,c,d等其他变量。由于MESI缓存一致性协议,所以线程B的变量b副本就会失效,即使主内存中的变量b并没有发生变化。

     如何解决缓存行共享问题?

       我们要保证不同线程的变量存在于不同的缓存行中,于是可以使用填充字节的方式来达到共享效果。

       Jdk1.6中可以加上volatile变量来进行填充。

jdk1.7在jvm优化过程中会认为是无效代码而被优化掉,可以使用继承模式达到效果。

Jdk1.8中官方提供字节填充注解@sun.misc.Contended,启动的时候需要加上该参数-XX:-RestrictContended。

3.6 CPU架构

  1. Final关键字
  2. JMM内存模型

八大原子操作

       Java内存模型定义的是一种抽象的概念,定义屏蔽java程序对不同的操作系统的内存访问差异。

       read(读取):从主内存读取数据

load(载入):将主内存读取到的数据写入工作内存中

use(使用):从工作内存读取数据来计算

assign(赋值):将计算好的值重新赋值到工作内存中

store(存储):将工作内存数据写入主内存

write(写入):将store过去的变量值赋值给主内存中的变量

lock(锁定):将主内存变量加锁,标识为线程独占状态

unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量

JMM内存可见性问题

       多个处理器同时对共享变量进行读改写操作(如i++),那么共享变量就会被多个处理器同时进行操作,这样的操作不是原子性。

     总线锁

       总线锁的含义是当处理器输出Lock # 信号时,将对主内存进行独占,其他处理器请求被阻塞。

缺点:在并发处理上效率过慢。若线程A与线程B修改的并不是同一共享变量,但当线程A独占后,线程B就必须等待线程A解锁后才能够操作。也就是说将并行转变成串行,失去了多线程的意义。

     MESI缓存一致性

       MESIModified Exclusive Shared Or Invalid(也称为伊利诺斯协议,是因为该协议由伊利诺斯州立大学提出)是一种广泛使用的支持写回策略的缓存一致性协议。

M: 被修改(Modified)

该缓存行只被缓存在该CPU的缓存中,并且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它CPU读取请主存中相应内存之前)写回(write back)主存。

当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。

E: 独享的(Exclusive)

该缓存行只被缓存在该CPU的缓存中,它是未被修改过的(clean),与主存中数据一致。该状态可以在任何时刻当有其它CPU读取该内存时变成共享状态(shared)

同样地,当CPU修改该缓存行中内容时,该状态可以变成Modified状态。

S: 共享的(Shared)

该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致(clean),当有一个CPU修改该缓存行中,其它CPU中该缓存行可以被作废(变成无效状态(Invalid))。

I: 无效的(Invalid)

该缓存是无效的(可能有其它CPU修改了该缓存行)。

       引入缓存行的概念,一个缓存行可以存储64个字节,当线程想修改修改共享变量时锁定该变量所在的缓存行,来达到高效的处理效果。不同的缓存行会互相隔离。

       缺点:缓存行中有多个共享变量,若修改了其中一个,其他的共享变量会同时失效。如缓存行中有a,b,c,d四个共享变量,线程A修改了共享变量a,那么b,c,d这三个共享变量也会失效,需要再次读取。

       解决缓存行共享变量失效问题:对缓存行中的其他存储空间进行字节填充,保证缓存行中不存储其他的共享变量。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值