04.JAVAEE之线程2

发布于:2024-04-29 ⋅ 阅读:(24) ⋅ 点赞:(0)

1.线程的状态

1.1 观察线程的所有状态

线程的状态是一个枚举类型 Thread.State

public class ThreadState {
    public static void main(String[] args) {
        for (Thread.State state : Thread.State.values()) {
            System.out.println(state);
       }
   }
}

 

NEW:Thread 对象已经有了.start 方法还没调用.

TÉRMINATED: Thread 对象还在,内核中的线程已经没了.

RUNNABLE: 就绪状态 (线程已经在 cpu 上执行了/线程正在排队等待上 cpu 执行)

TIMED WAITING: 阻塞. 由于 sleep 这种固定时间的方式产生的阻塞.

WAITING: 阻塞. 由于 wait 这种不固定时间的方式产生的阻塞

BLOCKED: 阻塞. 由于锁竞争导致的阻塞,

2. 多线程带来的的风险-线程安全 (重点)

2.1 观察线程不安全

static class Counter {
    public int count = 0;
    void increase() {
        count++;
   }
}
public static void main(String[] args) throws InterruptedException {
    final Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
       }
   });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
       }
   });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(counter.count);
}

2.2 线程安全的概念

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。 

2.3 线程不安全的原因

1.线程的随机调度

操作系统中, 线程的调度顺序是随机的 (抢占式执行).罪魁祸首,万恶之源,

count++ 这个操作,本质上,是分成三步进行的~~

站在 cpu 的角度上,count++ 是由 cpu 通过三个指令来实现的~~

1)load 把数据从内存, 读到 cpu 寄存器中,

2)add 把寄存器中的数据进行 +1

3)save 把寄存器中的数据, 保存到内存中

如果是多个线程执行上述代码,由于线程之间的调度顺序,是“随机"的,就会导致在有些调度顺序下,上述的逻辑就会出现问题

在多线程程序中,最困难的一点:线程的随机调度,使两个线程执行逻辑的先后顺序,存在诸多可能,我们必须要保证在所有可能的情况下,代码都是正确的!

以下是正确的执行顺序

不是按照此顺序,最终结果一定有bug,且最终值小于10w。 

2.两个线程,针对同一个变量进行修改

1)一个线程针对一个变量修改.ok

2)两个线程针对不同变量修改.ok

3)两个线程针对一个变量读取.ok 

3.修改操作,不是原子的.

此处给定的 count++ 就属于是 非原子 的操作.(先读,再修改)类似的,如果一段逻辑中,需要根据一定的条件来决定是否修改,也是存在类似的问题
假设 count++ 是原子的(比如有一个 cpu 指令,一次完成上述的三步)

4.内存可见性问题.

5.指令重排序

要想解决线程安全问题,就是要从上述方面入手。

1.系统内核里实现的->最初搞多任务操作系统的人,制定了"抢占式执行大的基调.在这个基调下,想做出调整是非常困难的。

2.有些情况下,可以通过调整代码结构,规避上述问题但是也有很多情况,调整不了。

3.通过加锁!!!

通过加锁, 就能解决上述问题.
如何给 java 中的代码加锁呢?
其中最常用的办法, 就是使用 synchronized 关键字!

synchronized 在使用的时候,要搭配一个 代码块{}进入{就会 加锁.出了}就会解锁.

在已经加锁的状态中,另一个线程尝试同样加这个锁,就会产生"锁冲突/锁竞争",后一个线程就会阻塞等待一直等到前一个线程解锁为止.

【锁对象到底用哪个对象?无所谓!!!对象是谁,不重要: 重要的是俩线程加锁的对象,是否是同一个对象.】

synchronized (locker) {
       count++;
 } 

()中需要表示一个用来加锁的对象这个对象是啥不重要,重要的是通过这个对象来区分两个线程是否在竞争同一个锁.

t2 线程由于锁的竞争, 导致 lock 操作出现阻塞, 阻塞到 t1 线程 unlock 之后t2 的 lock 才算执行完,此时 t2 就处在 blocked 状态下 。

阻塞就避免了下列的 load add save 和第一个线程操作出现穿插形成这种"串行"执行的效果此时线程安全问题就迎刃而解。

// 线程安全
public class Demo13 {
    // 此处定义一个 int 类型的变量
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Object locker2 = new Object();

        Thread t1 = new Thread(() -> {
            // 对 count 变量进行自增 5w 次
            for (int i = 0; i < 50000; i++) {
                synchronized (locker) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            // 对 count 变量进行自增 5w 次
            for (int i = 0; i < 50000; i++) {
                synchronized (locker) {
                    count++;
                }
            }
        });

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

        // 如果没有这俩 join, 肯定不行的. 线程还没自增完, 就开始打印了. 很可能打印出来的 count 就是个 0
        t1.join();
        t2.join();

        // 预期结果应该是 10w
        System.out.println("count: " + count);
    }
}
class Counter {
    public int count;

    synchronized public void increase() {
        count++;
    }

    public void increase2() {
        synchronized (this) {
            count++;
        }
    }

    synchronized public static void increase3() {

    }

    public static void increase4() {
        synchronized (Counter.class) {

        }
    }
}

// synchronized 使用方法
public class Demo14 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(counter.count);
    }
}

 

synchronized public void increase() {
        count++;
    }

    public void increase2() {
        synchronized (this) {
            count++;
        }
    }

二者等价

 

 synchronized public static void increase3() {

    }

    public static void increase4() {
        synchronized (Counter.class) {

        }
    }

二者等价

 3.synchronized 关键字-监视器锁monitor lock

3.1 synchronized 的特性

1) 互斥
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待. 
进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁 

synchronized 用的锁是存在 Java 对象头里的。

Java 的一个对象,对应的内存空间中,除了你自己定义的一些属性之外,还有一些自带的属性(在对象头中,其中就有属性表示当前对象是否已经加锁)

2) 刷新内存
synchronized 的工作过程: 

1. 获得互斥锁
2. 从主内存拷贝变量的最新副本到工作的内存
3. 执行代码
4. 将更改后的共享变量的值刷新到主内存
5. 释放互斥锁

3) 可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
所谓的可重入锁,指的是,一个线程,连续针对一把锁,加锁两次,不会出现死锁.满足这个要求,就是"可重入'不满足, 就是"不可重入"
上面把 synchronized 设计成" 可重入锁"就可以有效的解决上述死锁问题(让锁记录一下,是哪个线程给它锁住的后续再加锁的时候,如果加锁线程就是持有锁的线程就直接加锁成功!!!)
如何判断是不是最外层?
引用计数
锁对象中,不光要记录谁拿到了锁,还要记录,锁被加了几次,
每加锁一次,计数器就 + 1.
每解锁一次,计数器就 -1.

 关于死锁

1.一个线程,针对一把锁,连续加锁两次,如果是不可重入锁,就死锁了.(synchronized 不会出现.)
2.两个线程, 两把锁.(此时无论是不是可重入锁, 都会死锁).

1)t1 获取锁 A, t2 获取锁 B
2)t1 尝试获取 B, t2 尝试获取 A

public class Demo16 {
    private static Object locker1 = new Object();
    private static Object locker2 = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                // 此处的 sleep 很重要. 要确保 t1 和 t2 都分别拿到一把锁之后, 再进行后续动作.
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (locker2) {
                    System.out.println("t1 加锁成功!");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker2) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (locker1) {
                    System.out.println("t2 加锁成功!");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

死锁代码中两个 synchronized 是嵌套关系,

不是并列关系,嵌套关系说明,

是在占用一把锁的前提下,获取另一把锁.(并列关系,则是先释放前面的锁,再获取下一把锁,(不会死锁的)

3.N 个线程, M 把锁.(相当于 2 的扩充),此时,是更容易出现死锁的情况了~~
1个经典的描述 N 个线程 M 把锁死锁的模型,哲学家就餐问题

死锁,是属于比较严重的 bug(会直接导致线程卡住,也就无法执行后续工作了)

如何解决/避免死锁呢??

死锁的成因, 涉及到 四个 必要条件, 

1.互斥使用.(锁的基本特性).当一个线程持有一把锁之后,另一个线程也想获取到锁, 就要阻塞等待.
2.不可抢占.(锁的基本特性).当锁已经被线程1拿到之后,线程2 只能等线程1 主动释放,不能强行抢过来~

3.请求保持.(代码结构).一个线程尝试获取多把锁.(先拿到锁1 之后,再尝试获取锁2,获取的时候,锁1 不会释放).(吃着碗里的, 看着锅里的)

4.循环等待/环路等待. 等待的依赖关系,形成环了(钥匙锁车里了,车钥匙锁家里了)

1和2是锁的基本特性,不能破坏,

只要满足3和4就会出现死锁,

解决死锁,破坏3和4条件即可。

对于 3 来说, 调整代码结构,避免编写"锁嵌套" 逻辑(这个方案不一定好使,有的需求可能就是需要进行这种获取多个锁再操作)

对于 4 来说, 可以约定加锁的顺序, 就可以避免循环等待(针对锁,进行编号.比如约定,加多把锁的时候,先加编号小的锁,后加编号大的锁.)

public class Demo16 {
    private static Object locker1 = new Object();
    private static Object locker2 = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                // 此处的 sleep 很重要. 要确保 t1 和 t2 都分别拿到一把锁之后, 再进行后续动作.
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (locker2) {
                    System.out.println("t1 加锁成功!");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (locker2) {
                    System.out.println("t2 加锁成功!");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

4.volatile 关键字  

1)volatile 能保证内存可见性

volatile 修饰的变量 , 能够保证 " 内存可见性 ".
计算机运行的程序/代码,经常要访问数据
这些依赖的数据,往往会存储在 内存 中.(定义一个变量,变量就是在内存中.)
cpu 使用这个变量的时候,就会把这个内存中的数据,先读出来, 放到 cpu 的寄存器中再参与运算.(load)
cpu 读取内存的这个操作,其实非常慢!!!(快,慢,都是相对的)cpu 进行大部分操作,都很快.一旦操作到读/写内存,此时速度一下就降下来了
  • 读内存 相比于 读硬盘, 快几千倍,上万倍,
  • 读寄存器, 相比于读内存,又快了几干倍,上万倍

为了解决上述的问题,提高效率,此时编译器,就可能对代码做出优化,把一些本来要读内存的操作,优化成读取寄存器减少读内存的次数,也就可以提高整体程序的效率. 

public class Demo17 {
    private static  int isQuit = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (isQuit == 0) {
                // 循环体里啥都没干.
                // 此时意味着这个循环, 一秒钟就会执行很多很多次.
            }
            System.out.println("t1 退出!");
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            System.out.println("请输入 isQuit: ");
            Scanner scanner = new Scanner(System.in);
            // 一旦用户输入的值, 不为 0, 此时就会使 t1 线程执行结束.
            isQuit = scanner.nextInt();
        });
        t2.start();
    }
}

 1)load 读取内存中 isQuit 的值到寄存器中2)通过 cmp 指令比较寄存器的值是否是 0,决定是否要继续循环
由于这个循环,循环速度飞快.短时间内,就会进行大量的循环也就是进行大量的 load 和 cmp 操作.
此时,编译器/JVM 就发现了,虽然进行了这么多次 load,但是 load 出来的结果都一样的. 并且, load 操作又非常费时间,一次 load 花的时间相当于上万次cmp 了.
所以, 编译器就做了一个大胆的决定~~ 只是第一次循环的时候, 才读了内存后续都不再读内存了,而是直接从寄存器中,取出 isQuit 的值了

编译器优化:编译器的初心是好的,希望能够提高程序的效率.但是提高效率的前提是保证逻辑不变此时由于修改 isQuit 代码是另一个线程的操作, 编译器没有正确的判定所以编译器以为没人修改 isQuit, 就做出了上述优化. 也就进一步引起 bug 了】【这就是内存可见性问题】

volatile 就是解决方案
在多线程环境下,编译器对于是否要进行这样的优化, 判定不一定准,就需要程序猿通过 volatile 关键字,告诉编译器, 你不要优化!!!(优化,是算的快了,但是算的不准了)
编译器,也不是万能的.也会有一些自己短板的地方.此时就需要程序猿进行补充了只需要给isQuit 加上 volatile 关键字修饰,此时编译器自然就会禁止上述优化过程

 private static  volatile int isQuit = 0; 

此时没加 volatile,但是给循环里加了个 sleep此时,t1 线程是可以顺利退出的!!!
加了 sleep 之后, while 循环执行速度就慢了由于次数少了,load 操作的开销,就不大了,因此,优化也就没必要进行了.
没有触发 load 的优化,也就没有触发内存可见性问题了 

2) volatile 不保证原子性

volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性. 

3)synchronized 也能保证内存可见性

synchronized 既能保证原子性, 也能保证内存可见性.

5.wait和notify 

多线程中一个比较重要的机制.
协调多个线程的执行顺序的
本身多个线程的执行顺序,是随机的(系统随机调度,抢占式执行的)很多时候,是希望能够通过一定的手段,协调的执行顺序的,join 是影响到线程结束的先后顺序相比之下,此处是希望线程不结束,也能够有先后顺序的控制。

  • wait 等待,让指定线程进入阻塞状态
  • notify 通知,唤醒对应的阻塞状态的线程,
public class Demo18 {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("t1 结束!");
        });

        Thread t2 = new Thread(() -> {
            try {
                t1.join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("t2 结束!");
        });
        t1.start();
        t2.start();

        System.out.println("主线程结束!");
    }
}

 join
等待的过程和"主线程"没有直接联系,哪个线程调用 join, 哪个线程就阻塞

public class Demo19 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();

        synchronized (object) {
            System.out.println("wait 之前");
            // 把 wait 要放到 synchronized 里面来调用. 保证确实是拿到锁了的.
            object.wait();
            System.out.println("wait 之后");
        }
    }
}

 wait和notify

//释放锁的前提,是加锁

//wait 会持续的阻塞等待下去,直到其他线程调用 notify 唤醒,

public class Demo20 {
    public static void main(String[] args) {
        Object object = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (object) {
                System.out.println("wait 之前");
                try {
                    object.wait(3000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("wait 之后");
            }
        });

        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            synchronized (object) {
                System.out.println("进行通知");
                object.notify();
            }
        });

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

wait和notify都要放在synchronized

使用 wait notify 也可以避免"线程饿死'

这个等的状态,是阻塞的,啥都不做,不会占据 cpu

5.1 notify和notifyAll

notify->一次唤醒一个线程
notifyAll->一次唤醒全部线程 (唤醒的时候,wait 要涉及到一个重新获取锁的过程也是需要串行执行的)

调用 wait 不一定就只有一个线程调用.

N 个线程都可以调用 wait此时,当有多个线程调用的时候,这些线程都会进入阻塞状态

唤醒的时候,也就有两种方式了