一、多线程并发编程概念

首先需要分清并发和并行的概念。并发是指同一时间段有多个任务同时执行,而并行指的是在单位时间内有多个任务同时在执行。并发任务是建立在一段时间内cpu不断切换任务的基础上的,所以单核单线程cpu每个时间点只能执行一个任务,而并行是同一时间就有多个任务在执行,任务在不同的cpu上(针对单线程cpu而言)执行。

1、并发需要解决的问题

不同线程之间的读写操作,导致共享变量的内存可见性问题。当一个线程操作共享变量时,首先会从主内存复制共享变量到自己的工作内存,处理完成后将变量值更新到主内存。这就会造成线程间内存不可见性问题。为了解决这个问题,可以使用volatile关键字或者synchronized关键字。

2、volatile关键字

当一个变量被声明为volatile时,线程在写入变量时不会把值缓存到线程的工作内存中,而是会把值刷新回主内存;当读取该值时,也会从主内存重新获取最新值,而不使用当前线程工作内存中的值。但volatile只能保证刷新内存,而并不能变量操作时的原子性。比如

private static volatile Boolean  b=Boolean.FALSE;

void changeB() {
b=!b;
}

当b进行取反赋值操作时 ,其实是进行了两步操作,先获取b的值,再修改b的值。假如获取b的值之后,修改之前被阻塞,b被其他线程修改了,b再取反可能就获得了不是理想中的结果。

除此之外volatile还解决了指令重排序问题,详见(5、Java指令重排序)

3、synchronized关键字的内存语义

  • 进入synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存中清除。这样synchronized块内使用到该变量就不会从线程的工作内存中获取,而是从主内存中获取。
  • 退出synchronized块的内存语义是把synchronized块内对共享变量的修改刷新到主内存。

我们用一个例子说明volatile与synchronized内存语义:

//声明一个共享变量
    private static volatile Boolean  b=Boolean.FALSE;
    //开启线程池
    private static ThreadPoolExecutor pool= new ThreadPoolExecutor(5,5,1,
			TimeUnit.SECONDS,new ArrayBlockingQueue<>(200));
    @Test
	public void testSynchronized() throws InterruptedException, ExecutionException {
		pool.execute(()-> {
			//循环等待
				while(!b) {
					synchronized (b) {
					}
				}
			work("带有synchronized的线程");
		});
		pool.execute(()-> {
			//循环等待
			while(!b) {
			}
			work("普通线程1");
		});
		pool.execute(()-> {
			//循环等待
			while(!b) {
			}
			work("普通线程2");
		});

		Thread.sleep(1000);
		b=Boolean.TRUE;
		pool.shutdown();
		//等待线程执行完毕
		while (!pool.awaitTermination(10,TimeUnit.MILLISECONDS)) {

		}
		System.out.println("主线程结束!");
		Thread.sleep(100);
	}
    //输出信息
	void work(String text) {
		System.out.println(text);
	}

打印如下,所有的线程都不会进入while的无限循环,都能执行完毕。

ebs java 并发程序 java并发编程详解_ebs java 并发程序

然后把变量b的volatile 声明去掉,打印如下。可以发现主线程会一直等待,不会结束,因为线程池里面的线程进入了while的循环之中,只有synchronized声明的线程会执行完毕。

ebs java 并发程序 java并发编程详解_随机数_02

 可以看出volatile和synchronized起到了上面所说的内存语义效果。

4、java中的原子性操作

所谓原子性操作,就是执行一系列操作时,不会被线程调度机制打断的操作。这个定义不好下,英语是这样描述的(Atomic operation in Java is an operation that is guaranteed to not be interrupted in between by the thread scheduler)

如果不能保证操作时原子性的,那么就会出现线程安全问题。保证多个操作的原子性,有以下几种

  • 假如对共享变量除了赋值外,并不完成其他操作,比如取反、自增等,可以使用volatile
  • 使用synchronized
  • 使用CAS算法实现,比如java.util.concurrent.atomic包下许多类

5、Java指令重排序

java内存模型允许编译器和处理器对指令进行重排序,以提高性能,但对于存在数据依赖的指令不会进行重排序。

比如

int a=1;//(1)
int b=2;//(2)
int c=a+b;//(3)

上面代码中,第(3)步c依赖于a和b,所以(3)肯定是在(1)(2)步之后运行,但是(1)(2)步执行的先后就不一定了。

单线程不会因为指令重排序出现问题,但如果依赖存在于不同的线程中,可能会引起灾难。比如

private static Boolean flag = Boolean.FALSE;
    private static int count = 0;
    @Test
    public void testReorder() throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            //等待
            while (!flag) {
            }
            System.out.println(count);
        });

        Thread thread2 = new Thread(() -> {
            count = 2333;
            flag = true;
           
        });
        thread1.start();
        Thread.sleep(100);
        thread2.start();
        thread1.join();
        thread2.join();
    }

 上面的flag因为没有加volatile 声明,存在共享变量的内存可见性问题,所以会一直循环等待,不会输出任何东西。其实如果仅仅是解决内存可见性问题,也不一定会输出“2333”。因为除此之外,多线程还要面临指令重排序问题,可能flag=2333先执行,然后while判断通过,输出count等于0,再执行count=2333。不过flag变量添加volatile除了解决内存可见性问题,也可以解决重排序的问题。

写volatile变量时,可以确保volatile写之前的操作不会被编译器重排序到volatile写之后;读volatile变量时,可以确保volatile读之后的操作不会被重排序到volatile读之前。

6、伪共享

cpu与主内存之间会有一级或者多级的高速缓冲存储器(Cache),这些多级的cache一般是被集成到cpu内部,比如下面的两级Cache

ebs java 并发程序 java并发编程详解_Boo_03

 

当CPU需要访问某个变量时,会去看CPU Cache内是否有该变量,如果有就不会去主内存获取。但主内存和Cache的交换最小单位是行,一个Cache行可以存有多个变量,这就构成了伪共享。又由于同时只能有一个线程操作同一Cache行,这就造成了线程之间竞争关系,造成性能下降。为了解决这个伪共享引起的性能问题,jdk 8之前是采用一个类里添加多个类型变量,以此确保这个类能填充完一个Cache行,这样一个Cache行只有一个变量,比如

public final static FilledLong {
    public volatile long value=0L;
    public long p1,p2,p3,p4,p5,p6;
}

假设缓存行为64个字节,那么在FilledLong类里面填充6个Long类型变量,每个变量8个字节,再加上value就有56个字节。另外FilledLong是类对象,字节码的对象头占8个字节,总共64个字节,刚好占用一个Cache行。

JDK 8 提供了一个sun.misc . Contended 注解,用来解决伪共享问题。将上面代码修改为如下。

@sun.msc.Contended
public final static class FilledLong {
public volatile long val ue = OL ;
}

需要注意的是, 在默认情况下,@Contended 注解只用于Java 核心类, 比如此包下的类。如果用户类路径下的类需要使用这个注解, 则需要添加NM 参数:-XX:-RestrictContended 。填充的宽度默认为128 ,要自定义宽度则可以设置

-XX:ContendedPaddingWidth 参数。

7、悲观锁与乐观锁

悲观锁指对外界操作数据持有保守态度,认为数据很容易被其他线程修改,所以在数据被操作之前先对数据加锁,无论是读操作还是写操作。

而乐观锁则是认为一般情况下不需要加锁,只需要在写数据的时候检查新数据与原数据是否冲突即可,比如CAS(Compare and Swap)操作。

8、Random类与ThreadLocalRandom类

  • Random类可以获取随机数,比如
Random a=new Random();
//获取3以下的随机数,参数要大于0
a.nextInt(3)

 Random获取随机数大致流程是,

1、先用系统时间与特定的long类型数获得一个初始值,称为种子变量;

2、然后通过种子变量再获取新的long类型,覆盖旧的种子变量,成为新的种子变量;

3、通过新的种子变量获取int类型数,再取余数或者右移获得到一个伪随机数。

从上面可以看出,Random类生成随机数的过程伴随着种子变量的更替,这也是产生随机数的主要来源,为了防止多线程并发下,多个线程获取到同一个种子变量,在覆盖旧种子变量时,使用了AtomicLong类

ebs java 并发程序 java并发编程详解_Boo_04

也就确保了每次生成随机数时,种子变量都有更新。

但这也造成了多线程下获取随机数时的性能问题,线程之间需要竞争写入新的种子变量。

由于上面的问题源于线程之间竞争写入造成的,那么将种子变量保存在每个线程之间就可以解决了,就好像上面所说的ThreadLocal线程本地变量一样。

  • ThreadLocalRandom类便是如此,比如下面代码生成随机数
ThreadLocalRandom random= ThreadLocalRandom.current();
        random.nextInt();

 ThreadLocalRandom类的原理大致为:

  1. 首先在Thread类里,有一些变量,比如“threadLocalRandomSeed”,ThreadLocalRandom类初始化时,先获取这些变量在Thread类的偏移量,然后这些偏移量会根据时间进行覆盖,这样不同的线程就有不同的初始值了
static {
        try {
            UNSAFE = sun.misc.Unsafe.getUnsafe();
            Class<?> tk = Thread.class;
            SEED = UNSAFE.objectFieldOffset
                (tk.getDeclaredField("threadLocalRandomSeed"));
            PROBE = UNSAFE.objectFieldOffset
                (tk.getDeclaredField("threadLocalRandomProbe"));
            SECONDARY = UNSAFE.objectFieldOffset
                (tk.getDeclaredField("threadLocalRandomSecondarySeed"));
        } catch (Exception e) {
            throw new Error(e);
        }
    }

//

static final void localInit() {
        int p = probeGenerator.addAndGet(PROBE_INCREMENT);
        int probe = (p == 0) ? 1 : p; // skip 0
        long seed = mix64(seeder.getAndAdd(SEEDER_INCREMENT));
        Thread t = Thread.currentThread();
        UNSAFE.putLong(t, SEED, seed);
        UNSAFE.putInt(t, PROBE, probe);
    }

private static long initialSeed() {
        String sec = VM.getSavedProperty("java.util.secureRandomSeed");
        if (Boolean.parseBoolean(sec)) {
            byte[] seedBytes = java.security.SecureRandom.getSeed(8);
            long s = (long)(seedBytes[0]) & 0xffL;
            for (int i = 1; i < 8; ++i)
                s = (s << 8) | ((long)(seedBytes[i]) & 0xffL);
            return s;
        }
        return (mix64(System.currentTimeMillis()) ^
                mix64(System.nanoTime()));
    }
  1. 然后每次调用都在原来偏移量上累加上一个GAMMA的固定变量值,并且将结果保存在当前线程,这样每个线程都有自己的随机数,也就不存在线程间的竞争问题。

到此为止,Java多线程并发编程基础知识已经学习完毕,后面学习juc包下的原理和应用了。