java 多线程进行for循环 多线程执行for循环_多线程


这篇文章主要分享一下多线程和锁的基础使用;

1.为什么要使用多线程?

假如你刚刚下班回家,你想自己煮点粥喝,在煮粥的时候,盲猜你也不会待在电饭煲旁边就等着吧?干等的请回……在等待的这段时间,完全可以做一些别的事情,例如:打打游戏?洗个衣服?炒个菜?然后等粥煮好了之后,还可以一边喝粥一边看电影,这在某种程度也可以看做是多线程。

虽然一个CPU同一时刻只能执行一个程序,但是为什么我们电脑上的电影、微信中的聊天,还有播放的音乐等等似乎都是同步进行的?

这是因为CPU的执行速度非常快,它在这几个程序之间来回切换执行,让我们看起来就好像就是在同时进行一样;使用多线程,我们可以“同时”去做不一样的事情,将CPU的高性能充分利用起来;

2.什么是锁?

假如我现在想要上厕所,而当我进去之后,不希望别人再进来(毕竟这种事情是比较私密的嘛),那我拿一把锁将门锁住,后面来的人就无法进入,只能在外面等着,只有等到我出来之后,然后将锁交给下一个人,这个人再上;这就是锁的作用;

3.为什么使用锁?

来看一段代码:


private static int a = 0;
public static void main(String[] args) throws InterruptedException {
    Thread[] threads = new Thread[100];
    CountDownLatch latch = new CountDownLatch(threads.length);
    for (int i = 0;i < threads.length;i++){
        threads[i] = new Thread(() -> {
            for (int j = 0;j < 10000;j++){
                a++;
            }
            latch.countDown();
        });
    }
	//启动100个线程
    Arrays.stream(threads).forEach((t) -> t.start());
    latch.await();
    System.out.println(a);
}


启动100个线程对变量a进行累加操作,每个线程对a执行10000次累加操作,程序结束之后,正常情况下,我们得到a的值是1000000,但是实际呢?

我运行了五次代码,得到了五次不同的结果:969393、990229、981436、985627、987398

为什么会产生这种结果呢?

变量是如何被修改的:线程1拿到变量a的值,修改完成之后,将新值存入a中。这是一次完整的值的修改过程。

但是呢,我们启动了一百个线程,可能存在这种情况:线程1在修改完成,往a中存过程中,线程2又去拿到了a的值,也就是0,进行累加操作,等到线程2操作完成时,线程1已经将a的值改为了1,那么线程2继续把1存了进去,两次线程对a进行累加,但是值却累加了一次。这就可以解释为什么出现最终结果不足1000000的原因了。

而出现这种问题的本质就是没有使用

如果要保证结果的正确性,那么我们需要在线程对变量a进行修改的过程中将a保护起来,直到该线程对a完成了累加操作,这个时候别线程才可以对a进行操作;

而保护的操作就是加锁

上面的代码可以修改为:


private static int a = 0;
public static void main(String[] args) throws InterruptedException {
    Thread[] threads = new Thread[100];
    Object o = new Object();
    CountDownLatch latch = new CountDownLatch(threads.length);
    for (int i = 0;i < threads.length;i++){
        threads[i] = new Thread(() -> {
            synchronized (o) {
                for (int j = 0; j < 10000; j++) {
                    a++;
                }
                latch.countDown();
            }
        });
    }

    Arrays.stream(threads).forEach((t) -> t.start());
    latch.await();
    System.out.println(a);
}


这时程序运行的结果就可以保证为1000000

4.如何使用锁?

先来看一道面试题吧:

使用两个线程交替打印出 1a2b3c4d5e……


下面是多种实现方法:

version 1.0 LockSupport


static Thread t1 = null,t2 = null;
public static void main(String[] args) {
    char[] num = "123456789".toCharArray();
    char[] word = "abcdefghi".toCharArray();
    t1 = new Thread(() -> {
        for (char n : num) {
            System.out.print(n);
            java.util.concurrent.locks.LockSupport.unpark(t2);
            java.util.concurrent.locks.LockSupport.park();
        }
    });

    t2 = new Thread(() -> {
        for(char w : word){
            java.util.concurrent.locks.LockSupport.park();
            System.out.print(w);
            java.util.concurrent.locks.LockSupport.unpark(t1);
        }
    });

    t1.start();
    t2.start();
}


上面的代码中,先是定义两个线程t1和t2,然后给出了两个数组,分别为数字数组和字符数组,通过控制线程对两个数组中的内容交替进行输出;

程序一开始,如果最先调度的是t1,那么它先打印第一个数字,然后t1将“许可证”交给t2,再禁止t1被调度;
如果先调度的是t2,那么t2会被禁止调度,然后cpu就会去调度t1,重复上面的步骤,然后t2打印,然后再将“许可证”交给t1,等到两个线程循环结束之后,我们就可以拿到最终打印的结果了。

version 2.0 自旋


enum ReadyToRun{T1,T2}
public class Thread_CAS {
    static volatile ReadyToRun r = ReadyToRun.T1;
    public static void main(String[] args) {
        char[] num = "123456789".toCharArray();
        char[] word = "abcdefghi".toCharArray();

        new Thread(() -> {
            for (char n : num) {
                while (r != ReadyToRun.T1){}
                System.out.print(n);
                r = ReadyToRun.T2;
             }
        },"t1").start();
        new Thread(() -> {
            for (char w : word) {
                while (r != ReadyToRun.T2){}
                System.out.print(w);
                r = ReadyToRun.T1;
            }
        },"t2").start();
    }
}


上面的version2.0用到了自旋锁的概念,什么是自旋锁?简单说,一个等着上厕所的人在厕所门口打转;我们定义了一个枚举类和两个枚举变量T1和T2,我们新建一个枚举类型的变量r取值T1,程序启动之后,如果最先调度的是t2线程,可以看到这里是写了一个while循环的,循环体为空,如果其中的条件符合,那么它会形成死循环,这就是自旋,然后等到条件不满足之后,才会跳出循环执行下面的代码;当t2阻塞时,t1就有机会被调度了,很明显t1的while是不符合条件的,那么它直接往下走,打印出一个数字之后,然后将变量r取值为T2,这个时候,t1线程又回到了自旋的状态,那么调度器又去调度t2线程,这个时候t2中的while循环终于不符合条件了,代码向下走,然后打印出了一个字符,再将r取值为T1,重复上述步骤,最终,我们将得到打印的结果;

version 2.1


static AtomicInteger atomic = new AtomicInteger(1);
public static void atomic(){
    char[] num = "123456789".toCharArray();
    char[] word = "abcdefghi".toCharArray();
    new Thread(() -> {
        for (char n : num) {
            while (atomic.get() != 1){}
            System.out.print(n);
            atomic.set(2);
        }
    },"t1").start();

    new Thread(() -> {
        for (char w : word) {
            while(atomic.get() != 2){}
            System.out.print(w);
            atomic.set(1);
        }
    },"t2").start();
}


2.1版本的代码和2.0版本的核心思想是相同的,都是通过变量判断形成自旋来阻塞线程,然后迫使调度器去调度另一个线程,在另一个线程执行完成后再次修改变量值,之后形成自旋,然后调度器再调用另一个线程;

version 3.0 synchronized


public static void main(String[] args) {
    final Object o = new Object();
    char[] num = "123456789".toCharArray();
    char[] word = "abcdefghi".toCharArray();
    new Thread(() -> {
        synchronized (o){
            for (char n : num) {
                System.out.print(n);
                try {
                    o.notify();
                    o.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            o.notify();
        }
    },"t1").start();

    new Thread(() -> {
        synchronized (o){
            for (char w : word) {
                System.out.print(w);
                try {
                    o.notify();
                    o.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            o.notify();
        }
    },"t2").start();
}


version 3.0使用的是synchronized关键字、notify()wait()方法进行线程的之间的通信;这个概念也就就是文章开始第三个问题回答的代码展示;程序开始,调度器先进行线程t1的调度,t1拿到了对象o这把锁,然后打印了一个数字,接下来,o对等待的线程进行唤醒,也就是t2,因为现在只有两个我们定义的线程和守护线程;唤醒t2之后,然后t1释放锁进入等待状态,锁一旦被释放,那么t2这个时候又拿到了这把锁,执行代码,打印一个字符之后,然后它再唤醒t1线程,并释放锁进入等待,t1拿到锁……重复上述步骤,要注意的是:打印完成之后,必然是有一个线程处于等待状态的,我们必须将这个等待的线程进行唤醒,如果不进行这步操作,该程序将会进入死循环,由于我们不知道是哪个线程最后处于等待状态,我们对两个线程中的循环结束之后都进行唤醒操作就可以了。这里还有一个点:我们并不能控制CPU去调度哪一个线程,也存在CPU一上来就去调度t2线程的可能性(一般,一般情况下是调用在前面启动的线程);如果是这样的话,那么打印出来的结果就和我们需要的结果的先后顺序是相反的。
如果我们调换线程启动的顺序,那么最终得到的结果又会改成a1b2c3……(一般情况),针对这种情况,我们使用一个类CountDownLatch

version 3.1 CountDownLatch


private static CountDownLatch latch = new CountDownLatch(1);
public static void main(String[] args) {
    final Object o = new Object();
    char[] num = "123456789".toCharArray();
    char[] word = "abcdefghi".toCharArray();

    new Thread(() -> {
        synchronized (o){
            for (char n : num){
                System.out.print(n);
                latch.countDown();
                try {
                    o.notify();
                    o.wait();
                }catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            o.notify();
        }
    },"t1").start();
    new Thread(() -> {
        try {
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized (o){
            for (char w : word) {
                System.out.print(w);
                try {
                    o.notify();
                    o.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            o.notify();
        }
    },"t2").start();
}


Java的concurrent包里面的CountDownLatch其实可以把它看作一个计数器,只不过这个计数器的操作是原子操作,同时只能有一个线程去操作这个计数器,也就是同时只能有一个线程去减这个计数器里面的值。

你可以向CountDownLatch对象设置一个初始的数字作为计数值,任何调用这个对象上的await()方法都会阻塞,直到这个计数器的计数值被其他的线程减为0为止。

无论CPU最开始调度的是t1还是t2,都可以保证线程t1总是在t2之前被调度;

如果t2先被调度,它会被阻塞住,因为计数器的值现在为1,然后CPU又去调度线程t1,在一次循环结束之后,“计数器”的值被调整为0,这个时候t2就可以被调度了。

version 4.0


public static void main(String[] args) {
    char[] num = "123456789".toCharArray();
    char[] word = "abcdefghi".toCharArray();

    Lock lock = new ReentrantLock();
    Condition condition_t1 = lock.newCondition();
    Condition condition_t2 = lock.newCondition();

    new Thread(() -> {
        try {
            lock.lock();
            for (char n : num) {
                System.out.print(n);
                condition_t2.signal();
                condition_t1.await();
            }
            condition_t2.signal();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    },"t1").start();

    new Thread(() -> {
        try {
            lock.lock();
            for (char w : word) {
                System.out.print(w);
                condition_t1.signal();
                condition_t2.await();
            }
            condition_t1.signal();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    },"t2").start();
}


version 4.0中我们使用到了一个类ReentrantLock,也叫可重入锁

使用ReentrantLock的格式和synchronized是非常相似的,但是两者又有区别

ReetrantLock

synchronized

是否为独占锁



加/解锁

手动

自动

响应中断

可以响应中断

不可响应中断

是否可重入

可重入

可重入

锁是否公平

可实现公平锁机制(谁等的时间久谁拿)

非公平锁机制

上面在代码在实现上使用了lock对象获取了两个condition对象,还是一个执行完毕之后通过“唤醒”另一个线程,并让当前线程进入等待的执行逻辑,和version 3.0基本是一样的。但依然存在线程执行顺序的问题,因为一开始并不知道哪个线程会先被执行,所以推荐使用的version 1.0 / 2.0 / 2.1 / 3.1

无锁、偏向锁、轻量级锁和重量级锁的升级过程


java 多线程进行for循环 多线程执行for循环_bc_02


偏向锁:严格来讲的,偏向锁其实并不能算是锁,它更像是一个“标识”,还是以上厕所为例,上厕所的时候,我在门上写了一个纸条“有人”,这个纸条就相当于偏向锁。

而一旦另一个人来上厕所,看见门上贴着“有人”的纸条,这个时候我就需要将纸条换成锁了(别问我是怎么提上裤子出来换的),因为如果不换的话,那个人就可能会直接打开门进来,这样就会造成不好的结果了,哈哈哈哈。

那么我加锁的过程中,也就是偏向锁升级为轻量级锁的过程。而它升级的过程也可以简单总结为:有竞争就升级。

如果来的人特别多,这个时候我就需要质量更好的锁(为什么需要质量好的锁,因为我怕他们破门而入),也就是将偏向锁升级为重量级锁的过程

那什么时候轻量级锁会升级为重量级锁呢?

轻量级锁升级的为重量级锁也是因为线程之间的竞争,自旋时间过长,也就是当竞争大了之后,就不得不将锁升级,这样才能保证数据的安全;

通俗的说,门口没人加偏向锁;门口有四五个人等待,加轻量级锁;门口几百个人等着上厕所就需要加上重量级锁了,而也不会一直让他们(线程)在门口等着,如果都在门口等着,是极其消耗CPU资源的。所以会将他们(线程)安排到队列中等待,这样就不用占用CPU资源了。

偏向锁轻量级锁重量级锁的比较


优点

缺点

适用场景

偏向锁

加锁和解锁都不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距

如果线程间存在锁竞争,会带来额外的锁撤销的消耗

只有一个线程访问同步块

轻量级锁

竞争的线程不会阻塞,提高了程序的响应速度

如果始终得不到锁竞争的线程,使用自旋会消耗CPU

追求响应时间,同步块执行速度非常块

重量级锁

线程竞争不使用自旋,不会消耗CPU

线程阻塞,响应时间慢

追求吞吐量,同步块执行时间较长