2024年3月

引言

在多线程并发编程场景中,锁作为一种至关重要的同步工具,承担着协调多个线程对共享资源访问秩序的任务。其核心作用在于确保在特定时间段内,仅有一个线程能够对资源进行访问或修改操作,从而有效地保护数据的完整性和一致性。锁作为一种底层的安全构件,有力地防止了竞态条件和数据不一致性的问题,尤其在涉及多线程或多进程共享数据的复杂场景中显得尤为关键。

而了解锁的分类,能帮助我们何种业务场景下使用选择哪种锁。

Java中锁分类.jpg

基于锁的获取与释放方式分类

计划于所得获取与释放方式进行分类,Java中的锁可以分为:显式锁和隐式锁。

隐式锁

Java中的隐式锁(也称为内置锁或自动锁)是通过使用
synchronized
关键字实现的一种线程同步机制。当一个线程进入被
synchronized
修饰的方法或代码块时,它会自动获得对象级别的锁,退出该方法或代码块时则会自动释放这把锁。

在Java中,隐式锁的实现机制主要包括以下两种类型:

  1. 互斥锁(Mutex)
    : 虽然Java标准库并未直接暴露操作系统的互斥锁提供使用,但在Java虚拟机对
    synchronized
    关键字处理的底层实现中,当锁竞争激烈且必须升级为重量级锁时,会利用操作系统的互斥量机制来确保在同一时刻仅允许一个线程持有锁,从而实现严格的线程互斥控制。

  2. 内部锁(Intrinsic Lock)或监视器锁(Monitor Lock)
    : Java语言为每个对象内建了一个监视器锁,这是一个更高级别的抽象。我们可以通过使用
    synchronized
    关键字即可便捷地管理和操作这些锁。当一个线程访问被
    synchronized
    修饰的方法或代码块时,会自动获取相应对象的监视器锁,并在执行完毕后自动释放,这一过程对用户透明,故被称为隐式锁。每个Java对象均与一个监视器锁关联,以此来协调对该对象状态访问的并发控制。

优点:

  1. 简洁易用
    :程序员无需手动管理锁的获取和释放过程,降低了编程复杂性。
  2. 安全性
    :隐式锁确保了线程安全,避免了竞态条件,因为一次只有一个线程能持有锁并执行同步代码块。
  3. 异常处理下的自动释放
    :即使在同步代码块中抛出异常,隐式锁也会在异常退出时被释放,防止死锁。

缺点:

  1. 锁定粒度
    :隐式锁的粒度通常是对象级别,这意味着如果一个大型对象的不同部分实际上可以独立地被不同线程访问,但由于整个对象被锁定,可能导致不必要的阻塞和较低的并发性能。
  2. 不灵活
    :相对于显示锁(如
    java.util.concurrent.locks.Lock
    接口的实现类),隐式锁的功能较有限,无法提供更细粒度的控制,如尝试获取锁、定时等待、可中断的获取锁等高级特性。
  3. 锁竞争影响
    :在高并发环境下,若多个线程竞争同一把锁,可能会引发“锁争用”,导致性能下降,特别是在出现锁链和死锁的情况下。

适用场景:
隐式锁适用于相对简单的多线程同步需求,尤其是在只需要保护某个对象状态完整性,且无需过多关注锁策略灵活性的场合。对于要求更高并发性和更复杂锁管理逻辑的应用场景,显示锁通常是一个更好的选择。

显式锁

显式锁是由
java.util.concurrent.locks.Lock
接口及其诸多实现类提供的同步机制,相较于通过
synchronized
关键字实现的隐式锁机制,显式锁赋予开发者更为精细和灵活的控制能力,使其能够在多线程环境中精准掌控同步动作。显式锁的核心作用在于确保在任何时刻仅有一个线程能够访问关键代码段或共享数据,从而有效防止数据不一致性问题和竞态条件。

相较于隐式锁,显式锁提供了更为多样化的锁操作选项,包括但不限于支持线程在等待锁时可被中断、根据先后顺序分配锁资源的公平锁与非公平锁机制,以及能够设定锁获取等待时间的定时锁功能。这些特性共同增强了显式锁在面对复杂并发场景时的适应性和可调控性,使之成为解决高度定制化同步需求的理想工具。

日常开发中,常见的显式锁分类有如下几种:

  1. ReentrantLock
    :可重入锁,继承自
    Lock
    接口,支持可中断锁、公平锁和非公平锁的选择。可重入意味着同一个线程可以多次获取同一线程持有的锁。
  2. ReentrantReadWriteLock
    :读写锁,提供了两个锁,一个是读锁,允许多个线程同时读取;另一个是写锁,同一时间内只允许一个线程写入,写锁会排斥所有读锁和写锁。
  3. StampedLock
    :带版本戳的锁,提供了乐观读、悲观读写模式,适合于读多写少的场景,可以提升系统性能。

优点:

  1. 灵活控制
    :显式锁提供了多种获取和释放锁的方式,可以根据实际需求进行选择,比如中断等待锁的线程,设置超时获取锁等。
  2. 性能优化
    :在某些特定场景下,显式锁可以提供比隐式锁更好的性能表现,尤其是当需要避免死锁或优化读多写少的情况时。
  3. 公平性选择
    :显式锁允许创建公平锁,按照线程请求锁的顺序给予响应,保证所有线程在等待锁时有一定的公平性。

缺点:

  1. 使用复杂
    :相较于隐式锁,显式锁需要手动调用
    lock()

    unlock()
    方法,增加了编程复杂性,如果不正确地使用(如忘记释放锁或未捕获异常导致锁未释放),容易造成死锁或其他并发问题。
  2. 性能开销
    :在某些简单场景下,显式锁的额外API调用和锁状态管理可能带来额外的性能开销,尤其当公平锁启用时,由于需要维护线程队列和线程调度,可能会影响整体性能。
  3. 错误可能性
    :由于显式锁的操作更加细致,因此更容易出错,开发者需要具备较高的并发编程意识和技能才能妥善使用。

基于对资源的访问权限

按照线程对资源的访问权限来分类,可以将锁分为:独占锁(Exclusive Lock)和共享锁(Shared Lock)。

独占锁

独占锁(Exclusive Lock),又称排他锁或写锁,是一种同步机制,它确保在任一时刻,最多只有一个线程可以获得锁并对受保护的资源进行访问或修改。一旦线程获得了独占锁,其他所有试图获取同一锁的线程将被阻塞,直到拥有锁的线程释放锁为止。独占锁主要用于保护那些在并发环境下会被多个线程修改的共享资源,确保在修改期间不会有其他线程干扰,从而维护数据的一致性和完整性。

对于独占锁就像图书馆里的某本书,这本书只有唯一的一本。当一个读者想要借阅这本书时,他会去图书管理员那里登记并拿到一个“借书凭证”(相当于独占锁)。此时,这本书就被锁定了,其他读者无法借阅这本书,直至第一个读者归还书本并交回“借书凭证”。这就像是线程获得了独占锁,只有拥有锁的线程可以修改或操作资源(书本),其他线程必须等待锁的释放才能执行相应的操作。

而独占锁的实现方式,主要有如下两种:

  1. synchronized
    关键字:通过
    synchronized
    关键字实现的隐式锁,它是独占锁的一种常见形式,任何时刻只有一个线程可以进入被
    synchronized
    修饰的方法或代码块。
  2. ReentrantLock
    :可重入的独占锁,提供了更多的控制方式,包括可中断锁、公平锁和非公平锁等。

优点:

  1. 简单易用
    :对于
    synchronized
    关键字,语法简单直观,易于理解和使用。
  2. 线程安全
    :确保了对共享资源的独占访问,避免了并发环境下的数据竞争问题。
  3. 可重入性
    :像
    ReentrantLock
    这样的锁,支持同一个线程重复获取同一把锁,提高了线程间协作的便利性。

缺点:

  1. 粒度固定
    :对于
    synchronized
    ,锁的粒度是固定的,无法动态调整,可能导致不必要的阻塞。
  2. 缺乏灵活性
    :隐式锁不能主动中断等待锁的线程,也无法设置超时等待。
  3. 性能瓶颈
    :在高度竞争的环境中,
    synchronized
    可能会造成上下文切换频繁,效率低下;而显式锁虽提供了更灵活的控制,但如果使用不当也可能导致额外的性能损失。

共享锁

共享锁(Shared Lock)也称为
读锁
(Read Lock),是一种多线程或多进程并发控制的同步机制,它允许多个线程同时读取共享资源,但不允许任何线程修改资源。在数据库系统和并发编程中广泛使用,确保在并发读取场景下数据的一致性。

共享锁就像图书馆里有一套多人阅读的杂志合订本,这套合订本可以被多个读者同时翻阅,但是任何人都不能带走或在上面做标记。当一个读者要阅读时,他会向图书管理员申请“阅读凭证”(相当于共享锁)。如果有多个读者想阅读,图书管理员会给他们每人一份阅读凭证,这样大家都可以坐在阅览室里一起阅读这套合订本,但是都不能单独占有或改变它。在并发编程中,多个线程可以同时获取共享锁进行读取操作,但都不能修改数据,这就像是多个线程同时持有共享锁读取资源,但不允许在此期间进行写操作。

实现共享锁的关键机制是
读写锁(ReadWriteLock)
,这是一种特殊类型的共享锁机制,它巧妙地将对共享资源的访问权限划分为了读取权限和写入权限两类。在读写锁的控制下,多个线程可以同时进行对共享数据的读取操作,形成并发读取,而对数据的写入操作则采取独占式处理,确保同一时间段内仅有一个线程执行写入操作。在写入操作正在进行时,无论是其他的读取操作还是写入操作都会被暂时阻塞,直至写操作结束。

读写锁包含两种锁模式:
读锁(ReadLock)

写锁(WriteLock)
。当多个线程需要访问同一份共享数据时,只要这些线程都是进行读取操作,则都能成功获取并持有读锁,从而实现并行读取。然而,一旦有线程尝试进行写入操作,那么不论是其他正在执行读取的线程还是准备进行写入的线程,都无法继续获取读锁或写锁,直至当前写操作全部完成并释放写锁。这样,读写锁有效地平衡了读取密集型任务的并发性和写入操作的原子性要求。

优点:

  1. 提高并发性
    :对于读多写少的场景,共享锁可以使多个读取操作并行执行,显著提高系统的并发性能。
  2. 数据保护
    :在读取阶段避免了数据被意外修改,确保读取到的是稳定的数据状态。

缺点:

  1. 写操作阻塞
    :只要有共享锁存在,其他事务就不能对数据加排他锁(Exclusive Lock)进行写操作,这可能导致写操作长时间等待,降低系统的写入性能。
  2. 可能导致死锁
    :在复杂的事务交互中,如果没有合适的锁管理策略,共享锁可能会参与到死锁循环中,导致事务无法正常完成。
  3. 数据一致性问题
    :虽然共享锁能保护读取过程中数据不被修改,但并不能阻止数据在读取操作之后立即被其他事务修改,对于要求强一致性的应用可能不够。

如以下使用共享锁示例:

public class SharedResource {
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
    private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
    private int data;

    public void modifyData(int newData) {
        // 获取写锁(独占锁),在同一时刻只有一个线程可以获取写锁
        writeLock.lock();
        System.out.println(Thread.currentThread().getName() + " Modify Data");
        try {
            // 修改数据
            this.data = newData;
            // 数据修改相关操作...
        } finally {
            // 无论如何都要确保解锁
            writeLock.unlock();
        }
    }

    public int readData() {
        // 获取读锁(共享锁),允许多个线程同时获取读锁进行读取操作
        readLock.lock();
        System.out.println(Thread.currentThread().getName() + " Read Data");
        try {
            // 读取数据,此时其他读取线程也可以同时读取,但不允许写入
            return this.data;
        }finally {
            // 释放读锁
            readLock.unlock();
        }
    }

    public static void main(String[] args) {
        SharedResource resource = new SharedResource();

        Thread reader1 = new Thread(() -> System.out.println("Reader 1 reads: " + resource.readData()), "Reader1");
        Thread reader2 = new Thread(() -> System.out.println("Reader 2 reads: " + resource.readData()), "Reader1");

        Thread writer = new Thread(() -> resource.modifyData(42), "Writer1");

        reader1.start();
        reader2.start();
        writer.start();

        // 等待所有线程执行完成
        try {
            reader1.join();
            reader2.join();
            writer.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

打印结果:

image.png

在这个示例中,使用了
ReentrantReadWriteLock
来控制对
data
的读写操作。
readData()
方法使用读锁,允许多个线程同时读取数据,而
modifyData()
方法使用写锁,确保同一时间只有一个线程可以修改数据。这样就可以在并发场景下既保证数据读取的并发性,又避免了数据因并发写入而造成的不一致性问题。

基于锁的占有权是否可重入

按照锁的占有权是否可以重入,可以把锁分为:可重入锁以及不可重入锁。

可重入锁

可重入锁(Reentrant Lock)作为一种线程同步机制,具备独特的重入特性,即当线程已经获取了锁后,它可以再次请求并成功获得同一把锁,从而避免了在递归调用或嵌套同步块中产生的死锁风险。这意味着在执行锁保护的代码区域时,即便调用了其他同样被该锁保护的方法或代码片段,持有锁的线程也能顺利完成操作。

在多线程环境下,可重入锁扮演着至关重要的角色,它严格限制了同一时间只能有一个线程访问特定的临界区,有效防止了并发访问引发的数据不一致和竞态条件问题。此外,通过允许线程在持有锁的状态下重新获取该锁,可重入锁巧妙地解决了同类锁之间由于互相等待而形成的潜在死锁状况,从而提升了多线程同步的安全性和可靠性。

可重入锁主要可以通过以下三种方式实现:

  1. synchronized
    关键字:
    synchronized
    关键字实现的隐式锁就是一种可重入锁。
  2. ReentrantLock

    java.util.concurrent.locks.ReentrantLock
    类实现了
    Lock
    接口,提供了显式的可重入锁功能,它允许更细粒度的控制,例如支持公平锁、非公平锁,以及可中断锁、限时锁等。
  3. ReentrantReadWriteLock

    ReentrantReadWriteLock
    是一种特殊的可重入锁,它通过读写锁的设计,既实现了可重入特性的线程安全,又能高效地处理读多写少的并发场景。

优点:

  1. 线程安全性
    :确保了在多线程环境下的数据一致性。
  2. 可重入性
    :简化了代码编写,特别是在递归调用或嵌套同步块的场景中。
  3. 灵活性
    :显式可重入锁(如ReentrantLock)提供了更多控制选项,如尝试获取锁、设置锁的公平性、中断等待线程等。

缺点:

  1. 使用复杂性
    :相比于隐式锁(synchronized),显式锁需要手动管理锁的获取和释放,增加了编程复杂性和出错概率。
  2. 性能开销
    :在某些情况下,显式锁可能因为额外的API调用和状态管理而带来一定的性能开销。
  3. 死锁风险
    :如果开发者不谨慎地管理锁的获取和释放顺序,或者滥用锁的特性,可能会导致死锁的发生。尤其是对于显式锁,如果未正确释放,可能会导致资源无法回收。

以下为可重入锁使用示例:

public class ReentrantLockExample {

    private final Lock lock = new ReentrantLock();

    // 假设这是一个需要同步访问的共享资源
    private int sharedResource;

    public void increment() {
        // 获取锁
        lock.lock();

        try {
            // 在锁保护下执行操作
            sharedResource++;

            // 这里假设有个内部方法也需要同步访问sharedResource
            doSomeOtherWork();
        } finally {
            // 无论发生什么情况,最后都要释放锁
            lock.unlock();
        }
    }

    // 可重入的内部方法
    private void doSomeOtherWork() {
        // 因为当前线程已经持有锁,所以可以再次获取
        lock.lock();

        try {
            // 执行依赖于sharedResource的操作
            sharedResource -= 1;
            System.out.println("Inner method executed with sharedResource: " + sharedResource);
        } finally {
            // 释放锁
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        ReentrantLockExample example = new ReentrantLockExample();

        Thread thread1 = new Thread(example::increment);
        Thread thread2 = new Thread(example::increment);

        thread1.start();
        thread2.start();

        // 等待两个线程执行完毕
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 输出最终的sharedResource值
        System.out.println("Final sharedResource value: " + example.sharedResource);
    }
}

image.png

示例中,
increment()
方法和内部的
doSomeOtherWork()
方法都需要在获取锁的情况下执行。由于ReentrantLock是可重入的,所以在
increment()
方法内部调用
doSomeOtherWork()
时,线程仍然可以成功获取锁,并继续执行操作。当所有操作完成时,通过
finally
块确保了锁的释放。这样可以避免死锁,并确保在多线程环境下对共享资源的访问是线程安全的。

不可重入锁

不可重入锁(Non-reentrant Lock)是一种线程同步机制,它的核心特征在于禁止同一个线程在已经持有锁的前提下再度获取相同的锁。若一个线程已取得不可重入锁,在其执行路径中遇到需要再次获取该锁的场景时,该线程将会被迫等待,直至原先获取的锁被释放,其他线程才有可能获取并执行相关临界区代码。

此类锁机制同样服务于多线程环境下的资源共享保护,旨在确保同一时间内仅有单一线程能够访问临界资源,从而有效规避数据不一致性和竞态条件等问题。相较于可重入锁,不可重入锁在递归调用或涉及锁嵌套的复杂同步场景下表现出局限性,因其可能导致线程阻塞和潜在的死锁风险,降低了线程同步的灵活性和安全性。在实际开发中,除非有特殊的需求或场景约束,否则更建议采用可重入锁以实现更为稳健高效的线程同步控制。

在Java标准库中并没有直接提供名为“不可重入锁”的内置锁,通常我们会通过对比ReentrantLock(可重入锁)来理解不可重入锁的概念。理论上,任何不具备可重入特性的锁都可以认为是不可重入锁。但在实际应用中,Java的
synchronized
关键字修饰的方法或代码块在早期版本中曾经存在过类似不可重入的行为,但在目前Java的所有版本中,
synchronized
关键字所实现的锁实际上是可重入的。

优点:

  1. 简单性
    :从实现角度来看,不可重入锁可能在设计和实现上相对简单,因为它不需要处理递归锁定的复杂性。

缺点:

  1. 容易引发死锁
    :如果在一个线程已持有不可重入锁的情况下,它又试图再次获取同一把锁,那么就可能导致死锁。因为线程自身无法进一步推进,也无法释放已持有的锁,其他线程也无法获取锁,从而形成死锁状态。
  2. 限制性较强
    :不可重入锁极大地限制了线程的自由度,特别是在递归调用或含有嵌套锁的复杂同步结构中,往往无法满足需求。
  3. 线程栈跟踪复杂
    :对于编程者而言,需要更加小心地管理锁的层次结构,以防止无意间陷入死锁或资源浪费的情况。

基于锁的获取公平性

按照获取锁的公平性,也即请求顺序,将锁分为公平锁盒非公平锁。

公平锁

公平锁是一种线程调度策略,在多线程环境下,当多个线程尝试获取锁时,锁的分配遵循“先请求先服务”(First-Come, First-Served, FCFS)原则,即按照线程请求锁的顺序来分配锁资源。这意味着等待时间最长的线程将优先获得锁。公平锁可以有效避免某个线程长期得不到锁而导致的饥饿现象,所有线程都有平等获取锁的机会。它确保了线程的调度更加有序,减少了不公平竞争导致的不确定性。

公平锁的实现,可以通过
java.util.concurrent.locks.ReentrantLock
的构造函数传入
true
参数,可以创建一个公平的
ReentrantLock
实例。

ReentrantLock fairLock = new ReentrantLock(true); //创建一个公平锁

优点:

  1. 公平性
    :所有线程都遵循先来后到的原则,不会出现新来的线程总是抢占锁的现象,提高了系统的公平性和稳定性。
  2. 避免线程饥饿
    :减少或消除了由于锁的不公平分配而导致的线程长时间等待锁的情况。

缺点:

  1. 性能开销
    :公平锁在每次释放锁后,都需要检查是否有等待时间更长的线程,这通常涉及到线程调度的额外开销,可能会降低系统的整体并发性能。
  2. 线程上下文切换频繁
    :为了实现公平性,可能需要频繁地进行线程上下文切换,而这本身就是一种相对昂贵的操作。
  3. 可能导致“convoy effect”
    :即大量线程因等待前面线程释放锁而形成队列,即使后来的线程只需要很短时间处理,也会不得不等待整个队列中的线程依次完成,从而降低了系统的吞吐量。

以下使用公平锁示例:

public class FairLockExample {

    private final ReentrantLock fairLock = new ReentrantLock(true); // 使用true参数创建公平锁

    public void criticalSection() {
        fairLock.lock(); // 获取公平锁

        try {
            // 在此区域内的代码是临界区,同一时间只有一个线程可以执行
            System.out.println(Thread.currentThread().getName() + " entered the critical section at " + LocalDateTime.now());
            // 模拟耗时操作
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            fairLock.unlock(); // 释放公平锁
        }
    }

    public static void main(String[] args) {
        final FairLockExample example = new FairLockExample();

        Runnable task = () -> {
            example.criticalSection();
        };

        // 创建并启动多个线程
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(task);
            t.start();
        }
    }
}

image.png
在这个示例中,我们创建一个公平锁,我们创建了多个线程,每个线程都在执行
criticalSection
方法,该方法内部的代码块受到公平锁的保护,因此在任何时候只有一个线程能在临界区内执行。当多个线程尝试获取锁时,它们会按照请求锁的顺序来获取锁,确保线程调度的公平性。

非公平锁

非公平锁是一种线程调度策略,在多线程环境下,当多个线程尝试获取锁时,锁的分配不遵循“先请求先服务”(First-Come, First-Served, FCFS)原则,而是允许任何等待锁的线程在锁被释放时尝试获取,即使其他线程已经在等待队列中等待更长时间。非公平锁在某些场景下可以提高系统的并发性能,因为它允许刚释放锁的线程或者其他新到达的线程立刻获取锁,而不是强制排队等待。

实现方式也同公平锁,也是通过
java.util.concurrent.locks.ReentrantLock
的构造函数,但是我们要传入
false
参数,可以创建一个非公平的
ReentrantLock
实例。

ReentrantLock fairLock = new ReentrantLock(false); //创建一个非公平锁

优点:

  1. 性能优化
    :非公平锁在某些条件下可能会提供更高的系统吞吐量,因为它允许线程更快地获取锁,减少线程上下文切换次数,尤其在锁竞争不激烈的场景下,这种效果更为明显。

缺点:

  1. 线程饥饿
    :非公平锁可能导致某些线程长时间无法获取锁,即存在线程饥饿的风险,因为新到达的线程可能连续多次获取锁,而早前就已经在等待的线程始终得不到执行机会。
  2. 难以预测的线程调度
    :非公平锁会导致线程调度的不确定性增大,不利于系统的稳定性和性能分析。
  3. 潜在的连锁反应
    :非公平锁可能导致线程之间的依赖关系变得复杂,可能会引发连锁反应,影响整体系统的性能和稳定性。

基于对共享资源的访问方式

我们常说或者常用的悲观锁以及乐观锁就是以对共享资源的访问方式来区分的。

悲观锁

悲观锁(Pessimistic Lock)是一种并发控制策略,它假设在并发环境下,多个线程对共享资源的访问极有可能发生冲突,因此在访问资源之前,先尝试获取并锁定资源,直到该线程完成对资源的访问并释放锁,其他线程才能继续访问。悲观锁的主要作用是在多线程环境中防止数据被并发修改,确保数据的一致性和完整性。当一个线程获取了悲观锁后,其他线程必须等到锁释放后才能访问相应资源,从而避免了数据竞态条件和脏读等问题。悲观锁适合写操作较多且读操作较少的并发场景。

而悲观锁的实现可以通过
synchronized
关键字实现的对象锁或类锁。或者通过
java.util.concurrent.locks.Lock
接口的实现类,如
ReentrantLock

悲观锁虽然在并发场景下数据的一致性和完整性。但是他却有一些缺点,例如:

  1. 性能开销
    :频繁的加锁和解锁操作可能带来较大的性能消耗,尤其是在高并发场景下,可能导致线程频繁上下文切换。
  2. 可能导致死锁
    :如果多个线程间的锁获取顺序不当,容易造成死锁。
  3. 资源利用率低
    :在读多写少的场景下,悲观锁可能导致大量的读取操作等待,降低系统的并发能力和响应速度。

以下我们使用显式锁
ReentrantLock
实现一个悲观锁的示例:

import java.util.concurrent.locks.ReentrantLock;

public class Account {
    private final ReentrantLock lock = new ReentrantLock();
    private double balance;

    public void deposit(double amount) {
        lock.lock();
        try {
            // 持有锁进行存款操作
            balance += amount;
            // 更新账户余额的其他逻辑...
        } finally {
            lock.unlock(); // 保证锁一定会被释放
        }
    }

    public void withdraw(double amount) {
        lock.lock();
        try {
            // 持有锁进行取款操作
            if (balance >= amount) {
                balance -= amount;
                // 更新账户余额的其他逻辑...
            }
        } finally {
            lock.unlock();
        }
    }
}

乐观锁

乐观锁并不是Java本身提供的某种内置锁机制,而是指一种并发控制策略,它基于乐观假设:即在并发访问环境下,认为数据竞争不太可能发生,所以在读取数据时并不会立即加锁。乐观锁适用于读多写少的场景或者并发较少的场景。

Java中的乐观锁通过
CAS(Compare and Swap / Compare and Set)
算法实现,而数据库层面我们常使用版本号或者时间戳等进行控制。

CAS(Compare and Swap / Compare and Set)
: Java提供了
java.util.concurrent.atomic
包中的原子类,如
AtomicInteger

AtomicLong
等,它们通过CAS操作来实现乐观锁。CAS操作是一个原子指令,它只会修改数据,当且仅当该数据的当前值等于预期值时才进行修改。例如,
AtomicInteger
中的
compareAndSet
方法就是在乐观锁思想下实现的一种无锁化更新操作。

import java.util.concurrent.atomic.AtomicInteger;

AtomicInteger counter = new AtomicInteger(0);

// 乐观锁更新示例
public void incrementCounter() {
    while (true) {
        int expected = counter.get();
        int updated = expected + 1;
        if (counter.compareAndSet(expected, updated)) {
            // 更新成功,退出循环
            break;
        }
        // 更新失败,意味着有其他线程在此期间改变了值,继续尝试
    }
}

优点:

  • 更高的并发性能
    :因为在读取阶段不加锁,所以理论上可以支持更多的并发读取操作。
  • 降低死锁可能性
    :因为不存在长时间的加锁过程,从而减少了死锁的发生机会。

缺点:

  • 冲突处理成本
    :如果并发更新较为频繁,乐观锁会导致大量事务因并发冲突而重试甚至失败,这在某些情况下反而会增加系统开销。
  • 循环依赖问题
    :在遇到连续的并发更新时,乐观锁可能导致事务不断重试,形成“ABA”问题(即某个值被改回原值后再次更改)。

基于锁的升级以及优化

在Java中,JVM为了解决多线程环境下的同步问题,对锁机制进行了优化,将其分为偏向锁、轻量级锁和重量级锁三种状态。

偏向锁

偏向锁是一种Java虚拟机(JVM)在多线程环境下优化同步性能的锁机制,它适用于大多数时间只有一个线程访问同步代码块的场景。当一个线程访问同步代码块时,JVM会把锁偏向于这个线程,后续该线程在进入和退出同步代码块时,无需再做任何同步操作,从而大大降低了获取锁和释放锁的开销。偏向锁是Java内存模型中锁的三种状态之一,位于轻量级锁和重量级锁之前。

优点

对于没有或很少发生锁竞争的场景,偏向锁可以显著减少锁的获取和释放所带来的性能损耗。

缺点

  • 额外存储空间
    :偏向锁会在对象头中存储一个偏向线程ID等相关信息,这部分额外的空间开销虽然较小,但在大规模并发场景下,累积起来也可能成为可观的成本。

  • 锁升级开销
    :当一个偏向锁的对象被其他线程访问时,需要进行撤销(revoke)操作,将偏向锁升级为轻量级锁,甚至在更高竞争情况下升级为重量级锁。这个升级过程涉及到CAS操作以及可能的线程挂起和唤醒,会带来一定的性能开销。

  • 适用场景有限
    :偏向锁最适合于绝大部分时间只有一个线程访问对象的场景,这样的情况下,偏向锁的开销可以降到最低,有利于提高程序性能。但如果并发程度较高,或者线程切换频繁,偏向锁就可能不如轻量级锁或重量级锁高效。

轻量级锁

轻量级锁是一种在Java虚拟机(JVM)中实现的同步机制,主要用于提高多线程环境下锁的性能。它不像传统的重量级锁那样,每次获取或释放锁都需要操作系统级别的互斥操作,而是尽量在用户态完成锁的获取与释放,避免了频繁的线程阻塞和唤醒带来的开销。轻量级锁的作用主要是减少线程上下文切换的开销,通过自旋(spin-wait)的方式让线程在一段时间内等待锁的释放,而不是立即挂起线程,这样在锁竞争不是很激烈的情况下,能够快速获得锁,提高程序的响应速度和并发性能。

在Java中,轻量级锁主要作为JVM锁状态的一种,它介于偏向锁和重量级锁之间。当JVM发现偏向锁不再适用(即锁的竞争不再局限于单个线程)时,会将锁升级为轻量级锁。

轻量级锁适用于同步代码块执行速度快、线程持有锁的时间较短且锁竞争不激烈的场景,如短期内只有一个或少数几个线程竞争同一线程资源的情况。

在Java中,轻量级锁的具体实现体现在
java.util.concurrent.locks
包中的
Lock
接口的一个具体实现:
java.util.concurrent.locks.ReentrantLock
,它支持可配置为公平或非公平模式的轻量级锁机制,当使用默认构造函数时,默认是非公平锁(类似于轻量级锁的非公平性质)。不过,JVM的内置
synchronized
关键字在JDK 1.6之后引入了锁升级机制,也包含了偏向锁和轻量级锁的优化。

优点

  • 低开销
    :轻量级锁通过CAS操作尝试获取锁,避免了重量级锁中涉及的线程挂起和恢复等高昂开销。
  • 快速响应
    :在无锁竞争或者锁竞争不激烈的情况下,轻量级锁使得线程可以迅速获取锁并执行同步代码块。

缺点

  • 自旋消耗
    :当锁竞争激烈时,线程可能会长时间自旋等待锁,这会消耗CPU资源,导致性能下降。
  • 升级开销
    :如果自旋等待超过一定阈值或者锁竞争加剧,轻量级锁会升级为重量级锁,这个升级过程本身也有一定的开销。

重量级锁

重量级锁是指在多线程编程中,为了保护共享资源而采取的一种较为传统的互斥同步机制,通常涉及到操作系统的互斥量(Mutex)或者监视器锁(Monitor)。在Java中,通过
synchronized
关键字实现的锁机制在默认情况下就是重量级锁。确保任何时刻只有一个线程能够访问被锁定的资源或代码块,防止数据竞争和不一致。保证了线程间的协同工作,确保在并发环境下执行的线程按照预定的顺序或条件进行操作。

在Java中,重量级锁主要指的是由
synchronized
关键字实现的锁,它在JVM内部由Monitor实现,属于内建的锁机制。另外,
java.util.concurrent.locks
包下的
ReentrantLock
等类也可实现重量级锁,这些锁可以根据需要调整为公平锁或非公平锁。

优点

  • 强一致性
    :重量级锁提供了最强的线程安全性,确保在多线程环境下数据的完整性和一致性。
  • 简单易用

    synchronized
    关键字的使用简洁明了,不易出错。

缺点

  • 性能开销大
    :获取和释放重量级锁时需要操作系统介入,可能涉及线程的挂起和唤醒,造成上下文切换,这对于频繁锁竞争的场景来说性能代价较高。
  • 延迟较高
    :线程获取不到锁时会被阻塞,导致等待时间增加,进而影响系统响应速度。

重量级锁适用于

  • 高并发且锁竞争激烈的场景,因为在这种情况下,保证数据的正确性远比微小的性能损失重要。
  • 对于需要长时间持有锁的操作,因为短暂的上下文切换成本相对于长时间的操作来说是可以接受的。
  • 当同步代码块中涉及到IO操作、数据库访问等耗时较长的任务时,重量级锁能够较好地防止其它线程饿死。

在Java中,偏向锁、轻量级锁和重量级锁之间的转换是Java虚拟机(JVM)为了优化多线程同步性能而设计的一种动态调整机制。转换条件如下:

  1. 偏向锁到轻量级锁的转换

    当有第二个线程尝试获取已经被偏向的锁时,偏向锁就会失效并升级为轻量级锁。这是因为偏向锁假定的是只有一个线程反复获取锁,如果有新的线程参与竞争,就需要进行锁的升级以保证线程间的互斥。

  2. 轻量级锁到重量级锁的转换

    当轻量级锁尝试获取失败(CAS操作失败),即出现了锁竞争时,JVM会认为当前锁的持有者无法很快释放锁,因此为了避免后续线程无休止地自旋等待,会将轻量级锁升级为重量级锁。这个转换过程通常发生在自旋尝试获取锁达到一定次数(自旋次数是可配置的)或者系统处于高负载状态时。

  3. 偏向锁到重量级锁的转换

    如果当前线程不是偏向锁指向的线程,那么首先会撤销偏向锁(解除偏向状态),然后升级为轻量级锁,之后再根据轻量级锁的规则判断是否需要进一步升级为重量级锁。

锁状态的转换是为了在不同的并发环境下,既能保证数据的正确性,又能尽可能地提高系统性能。JVM会根据实际情况自动调整锁的状态,无需我们手动干预。

分段锁

分段锁(Segmented Lock 或 Partitions Lock)是一种将数据或资源划分为多个段(segments),并对每个段分配单独锁的锁机制。这样做的目的是将锁的粒度细化,以便在高并发场景下提高系统的并发性能和可扩展性,特别是针对大型数据结构如哈希表时非常有效。通过减少锁的粒度,可以使得在多线程环境下,不同线程可以同时访问不同段的数据,减小了锁争抢,提高了系统的并行处理能力。在大规模数据结构中,如果只有一个全局锁,可能会因为热点区域引发大量的锁竞争,分段锁则能有效地分散锁的压力。

Java中,分段锁在实现上可以基于哈希表的分段锁,例如Java中的
ConcurrentHashMap
,将整个哈希表分割为多个段(Segment),每个段有自己的锁,这样多个线程可以同时对不同段进行操作。例外也可以基于数组或链表的分段锁,根据数据索引将数据分布到不同的段,每段对应一个独立的锁。

分段锁可以提高并发性能,减少锁竞争,增加系统的并行处理能力。其优点:

  1. 减小锁的粒度
    :通过将一个大的锁分解为多个小锁,确实可以提高并发程度,降低锁的粒度,减少单点瓶颈,提高系统性能。
  2. 减少锁冲突
    :确实可以降低不同线程间对锁资源的竞争,减少线程等待时间,从而提升并发度。
  3. 提高系统的可伸缩性
    :通过分段,可以更好地支持分布式和集群环境下的系统扩展,增强系统的并发处理能力和可扩展性。

分段锁也有一些缺点:

  1. 增加了锁的管理复杂度
    :确实需要额外的内存和复杂度来管理和维护多个锁,确保锁的正确使用和释放,以及在不同分段间的一致性和可靠性。
  2. 可能导致线程饥饿
    :分段不合理或者热点分段可能导致某些线程长时间等待锁资源,出现线程饥饿问题。
  3. 可能会降低并发度
    :如果分段策略设计不当,可能会增加锁竞争,降低并发性能。设计合理的分段策略和锁协调机制对于分段锁的效能至关重要,同时也增加了开发和维护的复杂度。
  4. 内存占用
    :每个分段所需的锁信息和相关数据会占用额外的内存空间,对系统内存有一定的消耗。

分段锁适用于大数据结构的并发访问,如高并发环境下对哈希表的操作。以及分布式系统中,某些分布式缓存或数据库系统也采用类似的分片锁策略来提高并发性能。

自旋锁

自旋锁(Spin Lock)是一种简单的锁机制,用于多线程环境中的同步控制,它的工作原理是当一个线程试图获取已经被另一个线程持有的锁时,该线程不会立即进入睡眠状态(阻塞),而是不断地循环检查锁是否已经被释放,直到获取到锁为止。这种“循环等待”的行为被称为“自旋”。自旋锁主要用于保证同一时刻只有一个线程访问临界区资源,防止数据竞争。相比传统阻塞式锁,自旋锁在持有锁的线程很快释放锁的情况下,可以减少线程的上下文切换开销。

我们使用
AtomicInteger
实现一个简单的自旋锁:

import java.util.concurrent.atomic.AtomicInteger;

class SimpleSpinLock {
    private AtomicInteger locked = new AtomicInteger(0);

    public void lock() {
        while (locked.getAndSet(1) == 1) {
            // 自旋等待
        }
        // 已经获取锁,执行临界区代码
    }

    public void unlock() {
        locked.set(0);
    }
}

自旋锁优点

  • 对于持有锁时间很短的场景,自旋锁能有效减少线程上下文切换,提高系统性能。
  • 自旋锁适用于多处理器或多核心系统,因为在这种环境下,线程可以在等待锁释放时继续占用CPU时间。

自旋锁缺点

  • 如果持有锁的线程需要很长时间才能释放锁,自旋锁会导致等待锁的线程持续消耗CPU资源,浪费CPU周期。
  • 在单处理器系统中,自旋锁的效率不高,因为等待锁的线程无法执行任何有用的工作,只是空转。

死锁

说到各种锁,就会想到死锁问题,对于死锁有兴趣的可以参考这篇文章:
这里就不过多赘述。

总结

本文介绍了多种Java中的锁机制,包括可重入锁(Reentrant Lock)、公平锁、非公平锁、悲观锁、乐观锁、偏向锁、轻量级锁、重量级锁、分段锁以及自旋锁。这些锁各有优缺点和适用场景,如可重入锁支持递归锁定,悲观锁确保数据一致性但可能引起性能开销,乐观锁在读多写少场景下表现优异,偏向锁和轻量级锁用于优化单线程重复访问,重量级锁提供严格的互斥性,分段锁通过减小锁粒度提高并发性能,而自旋锁则在短时间内获取锁的场景中能减少线程上下文切换。根据不同的并发需求和性能考量,开发者可以选择合适的锁机制。

本文已收录于我的个人博客:
码农Academy的博客,专注分享Java技术干货,包括Java基础、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中间件、架构设计、面试题、程序员攻略等

前言

在现代商业环境中,预测销售数据和实际成本是每个公司CEO和领导都极为重视的关键指标。然而,由于市场的不断变化,准确地预测和管理这些数据变得愈发具有挑战性。为了应对这一挑战,建立一个高效的系统来管理和审查销售数据的重要性不言而喻。今天小编就将为大家介绍一下如何使用葡萄城公司的纯前端表格控件SpreadJS实现一个预算编制系统。

环境准备

Node.js

VSCode
代码编辑器

完整代码Github地址
(可在阅读本文时配合参考使用)

使用代码实现的在线Demo地址
(可在阅读本文时配合参考使用)

实现步骤

1)自定义菜单栏

上图中红色方框划出来的菜单栏叫做在线表格编辑器(Designer),Designer的菜单提供了各种定制化的能力,如新增菜单,修改菜单执行的逻辑,修改图标,修改文字以及删除菜单等功能。

观察上图中,首先新建了一个“预算操作(定制按钮)”tab ,此tab内容包括了三部分,分别是“预算类型”、“预算编制”、“数据”。对应的代码如下:

let config = JSON.parse(JSON.stringify(GC.Spread.Sheets.Designer.DefaultConfig));
config.ribbon.push(
    {
    id: "fill-custom",
    text: "预算操作(定制按钮)",
    buttonGroups: [
    {
        label:"预算类型",commandGroup:{}  
    },    
    {
        label: "预算编制", commandGroup:{}
       
    },
    {
        label: "数据", commandGroup:{}
      
    }]
})
designer.setConfig(config)

通过上述代码,我们来看看实现结果:

Ok ,发现添加了一个“预算操作(定制按钮)”tab,点击此tab,已经有了基础框架

接下来,继续,我们设置当前tab为激活状态,加上active属性,这样子页面初始化后看到的当前tab就是“预算操作(定制按钮)”

{
    id: "fill-custom",
    text: "预算操作(定制按钮)",
    active: true,
    buttonGroups: []
}

接下来,我们设置预算模型command, 我们再次看上面的第一张图,发现预算类型只有一个节点,且该节点是一个下拉框。对应的代码实现方式如下:

{
    label:"预算类型",
    commandGroup: {
        children: ["selectBudgetType"]
    }
}, 

接下来定义“selectBudgetType”,代码如下所示:( 关于定义下拉框子菜单的实现方法详细解释,可以参考此篇
文章

const budgetType = {
    cost: 'cost' ,   //成本预算
    sales: 'sales'   //销售预算
}
let selectBudgetType = {
    text: "选择预算类型",
    comboWidth: 120,
    type:"comboBox",
    commandName: "selectBudgetType",
    dropdownList:[
        {
            text:"成本预算",
            value: budgetType.cost
        },{
            text:"销售预算",
            value:budgetType.sales
        },
    ],
    execute:(context,propertyName) => {
        console.log('选择',propertyName)
    },
}
config.commandMap = {selectBudgetType}
designer.setConfig(config)

上述代码为子菜单“selectBudgetType”定义了text,type ,以及dropdownList以及点击事件。exexute方法中propertyName对应的是dropdownList中的value值。

结果如下:

上述代码已经熟悉了如何定义菜单以及子菜单,接下来的两个子菜单(预算编制和数据)就不重复详细介绍,直接上代码:

config.ribbon.push(
    {
    id: "fill-custom",
    text: "预算操作(定制按钮)",
    active: true,
    buttonGroups: [
    {
        label:"预算类型",
        commandGroup: {
            children: ["selectBudgetType"]
        }
    },    
    {
        label: "预算编制",
        thumbnailClass: "ribbon-thumbnail-editing",
        commandGroup: {
            children: [ "distributeTask"]
        }
    },
    {
        label: "数据",
        commandGroup: {
            children: ["clearLocalData"]
        }
    }]
})
config.commandMap = {
    selectBudgetType:{
        text: "选择预算类型",
        comboWidth: 120,
        type:"comboBox",
        commandName: "selectBudgetType",
        dropdownList:[
            {
                text:"成本预算",
                value: budgetType.cost
            },{
                text:"销售预算",
                value:budgetType.sales
            },
        ],
        execute:(context,propertyName) => {
              console.log('选择',propertyName)
        }
    },
    distributeTask: {
        title: "下发预算任务",
        text: "预算编制",
        iconClass: "distribute-icon",
        bigButton: true,
        commandName: "distributeTask",
        execute: function (context) {
           
        }
    },
    clearLocalData: {
        title: "清除本地缓存",
        text: "清除本地缓存",
        iconClass: "clear-local-icon",
        bigButton: true,
        commandName: "clearLocalData",
        execute: function () {
            localStorage.clear()
        }
    },
}
designer.setConfig(config)

icon相关代码,注意iconClass要添加相应的背景图片。

.clear-local-icon {
  background: url("../assets/clear.png");
  background-size: 35px 35px;
}
.distribute-icon {
  background: url("../assets/distribute.png");
  background-size: 35px 35px;
}

上述三个子菜单中的execute方法需要自定义,如选择选择预算类型后,模板需要进行切换。

2)设置模板

当“选择预算类型”选择“成本预算”时,加载
cost.json
文件

当“选择预算类型”选择“销售预算”时,加载
sales.json
文件

let selectBudgetType = {
    text: "选择预算类型",
    comboWidth: 120,
    type:"comboBox",
    commandName: "selectBudgetType",
    dropdownList:[
        {
            text:"成本预算",
            value: budgetType.cost
        },{
            text:"销售预算",
            value:budgetType.sales
        },
    ],
    execute:(context,propertyName) => {
        if(propertyName){
            selectedBudget.value = propertyName
            loadTemplate(context,propertyName,taskId)
         }  
    },
    getState:(context)=>{
        return selectedBudget.value
    },
}

const loadTemplate = async (designer,fileName,taskId) => {
    let templateStr = await BusinessType.getTemplate(fileName)
    let template = JSON.parse(templateStr)
    let spread = designer.getWorkbook()
    spread.fromJSON(template)  
}

上述代码介绍了【选择预算类型】下拉框选中的事件,选中后,导入对应的json文件,通过fromJSON进行导入。

对于需要设置的模板,可以通过
Designer
中菜单快速设计,其菜单基本与Excel一致,对于熟悉Excel的用户来说,真的很友好。

3)设置数据源

下面小编以“销售预算”模板为例,介绍如何设置数据源:

点击“数据”tab,接下来点击“工作表绑定”,此时出现右侧字段列表Panel。发现字段列表中存在“id”和“name ",这是因为在模板(sales.json)中已经设置好字段。

此时进行数据绑定setDataSource():

const bindInitialData = (spread,type,taskId) => {
    // 绑定初始数据
    let data = defaultBudgetData[type]
    let source = new GC.Spread.Sheets.Bindings.CellBindingSource(data)
    spread.suspendPaint()
    let sheetCount = spread.getSheetCount()
    for(let i=0; i<sheetCount;i++){
        let sheet = spread.getSheet(i)
        sheet.setDataSource(source)
    }
    spread.resumePaint()
    taskId.value = data.id
}
const defaultBudgetData = {
  [budgetType.cost]: {
    id:`成本NV-${getNowTime()}`,//项目编号
    name:'',    //项目名称
    city: '',   //项目所在地
    customer: '',    //客户名称
    price: 0        //本次报价
},
  [budgetType.sales]:{
    id: `销售NV-${getNowTime()}`,
    name:''
  }
}

4)任务下发

(1)在任务下发前 ,需要确认预测因子,预测因子基于往年数据,确认接下来的销售计划。

(2)填写预算名称 。

(3)点击“预算编制”菜单。

distributeTask: {
    title: "下发预算任务",
    text: "预算编制",
    iconClass: "distribute-icon",
    bigButton: true,
    commandName: "distributeTask",
    execute: function (context) {
        confirmDistribute(context,selectedBudget,distributeVisible)
    }
},

const confirmDistribute = (context,selectBudgetType,distributeVisible) => {
    /**预算任务下发时必填信息校验 */
    let sheet = context.getWorkbook().getSheet(0)
    let source = sheet.getDataSource().getSource()
    for(let key in source){
        if(!source[key]){
            ElMessage.error("红色区域必填项信息缺失")
            return
        }
    }
    // 确认是否下发编制任务
    ElMessageBox.confirm("确认下发预算编制任务吗?","下发确认",{
        confirmButtonText:'确认',
        cancelButtonText:"取消",
        type:'warning'
    }).then(() => {
        // 确认下发,存储当前预算模板,下发部门信息
        saveBudgetRecord(context, selectBudgetType)
        distributeBudgetTask(context,distributeVisible)
    }).catch(() => {
        ElMessage({
            type:'error',
            message:'取消发布'
        })
    })
}

在上述代码confirmDistribute()中,通过getDataSource()获取数据源,来判断红色区域的必填项是否填写。当确认下发任务后,执行saveBudgetRecord 、distributeBudgetTask方法。

5)填写任务

当确定下发任务后,对不同部门生成不同的编制链接。此弹窗可以参考代码中的OnlineDesigner.vue文件。

部门经理获取链接,打开链接,显示内容是自己部门区域预算明细填写和实际填写,此时,部门经理可以在左侧蓝色区域填写,而其他单元格不能编辑,这个是怎么做到的呢?具体可以参考
这篇文章
中第二点对少部分单元格可以编辑。

var defaultStyle = new GC.Spread.Sheets.Style();
defaultStyle.locked = false;
sheet.setDefaultStyle(defaultStyle, GC.Spread.Sheets.SheetArea.viewport);
// 设置第1行不可编辑
var style = new GC.Spread.Sheets.Style();
style.locked = true;
style.backColor = "red";
sheet.setStyle(0, -1, style);
// 设置表单保护
sheet.options.isProtected = true;  

介绍完单元格的权限后,我们再来看下上图中还有哪些值得说一说的功能。

(1)添加签名

当经理设置完预算后,可以在区域总监单元格右键,看到多出来两个菜单“添加签名”和“添加手写签名”。

所以接下来介绍如何在右键菜单中新增菜单并定义其事件,代码如下:

let signMenu = {
    text:"添加签名",
    name:"signName",
    command:"signMenuCommand",
    workArea: "viewport"
}
spread.contextMenu.menuData.push(signMenu)

上述代码在spread.contextMenu.menuData中push了一条对象,结果就是可以在右键菜单中看见“添加签名菜单” ,观察到上述对象定义了command属性,接下来定义“signMenuCommand”:

let signMenuCommand = {
    canUndo: true,
    execute: function(context,options,isUndo){
        if(isUndo){
            GC.Spread.Sheets.Commands.undoTransaction(context,options)
            return true
        }else{
            GC.Spread.Sheets.Commands.startTransaction(context,options)
            let {activeRow,activeCol,sheetName} = options
            let sheet = context.getSheetFromName(sheetName)
            sheet.getCell(activeRow,activeCol).value(user).backColor('#F7A711').font('bold normal 15px normal')
            GC.Spread.Sheets.Commands.endTransaction(context,options)
            return true
        }
    }
}
commandManager.register("signMenuCommand",signMenuCommand,null, false, false, false, false)

上述代码是SpreadJS中注册命令的方法,并提供了撤销机制。我们主要看else里面的内容:首先从上下文context中获取sheet对象,接着获取单元格并设置内容、背景色、字体等。上述两段代码就实现了在SpreadJS中在右键菜单中添加菜单,并完整相应的点击逻辑。

(2)添加手写签名

接下来,我们看看如何设置“添加手写签名”:

// 注册签名的右键菜单
let commandManager = spread.commandManager()
let signMenu = {
    text:"添加手写签名",
    name:"handWriteName",
    command:"handWriteCommand",
    workArea: "viewport"
}
spread.contextMenu.menuData.push(signMenu)
let handWriteCommand = {
    canUndo: false,
    execute: function(context,options,isUndo){
        showWriteDialog.value = true
    }
}
commandManager.register("handWriteCommand",handWriteCommand,null, false, false, false, false)

添加菜单和菜单命令的方式与前文一致,不同的就是execute的执行逻辑。

最后,签名设置后,就可以点击“提交预算”按钮。

对了,如果数据不符合预期,可能会有红色预警,比如

这个是SpreadJS的数据验证功能,我们可以通过UI方式设置。如下图所示:

6)编制完成

当所有部门经理填写完预算后,就可以点击“编制完成”

此时点击“预算审核”,预算类型设置为“销售预算”,可以看到有一条待审核的标签,点进去看看。

看到了我们熟悉的页面

此时点击“华东”sheet看看

这个时候就看到了华东部门经理填写的销售预测数据,这个时候点击右上角的“导入年度实际销售数据”看看。

嗯,表格内容基本上填写完整了,这时候审核员(副总经理)如果对销售数据表示满意,可以签上自己的大名,就可以点击“审核完毕了”

当四个sheet都“审核完毕”,此时返回首页,发现标签变了。

这时候可以进行打印了。

最后

简单的全面预算编制系统就算介绍完了。大家可以在
Demo地址
实际体验下。总结下本文介绍的SpreadJS的几个知识点:

1、自定义Designer菜单

2、导入模板

3、设置数据源

4、获取数据源

5、自定义右键菜单

6、单元格权限

如果您想了解更多的信息,欢迎点击这篇
参考资料
查看。

扩展链接:

【干货放送】财务报表勾稽分析要点,一文读尽!

为什么你的财务报表不出色?推荐你了解这四个设计要点和!

纯前端类 Excel 表格控件在报表勾稽分析领域的应用场景解析

技术背景

IPython是一个非常灵活好用的python终端工具,而且比Python自带的终端工具还多了命令行高亮和自动索引的功能,也是常用的Jupyter Notebook的基础工具。在使用IPython的过程中可以使用它的一些独有的功能——直接运行Shell命令行,和魔术命令。本文介绍的是其中一种魔术命令——重新加载函数模块。

魔术命令配置

在默认配置下,IPython在运行一个模块之前不会去重载这个模块,因此你这个模块在运行程序的过程中怎么改,都不会影响程序的结果:

In [1]: !echo "f=lambda: print('func1')" > test_ipython.py

In [2]: from test_ipython import f

In [3]: f()
func1

In [4]: !echo "f=lambda: print('func2')" > test_ipython.py

In [5]: from test_ipython import f

In [6]: f()
func1

那么假如说我们希望这个函数修改之后被重载,应该怎么操作呢?IPython的魔术命令autoreload支持了这样的功能:

In [8]: %load_ext autoreload

In [9]: %autoreload 2

In [10]: !echo "f=lambda: print('func1')" > test_ipython.py

In [11]: from test_ipython import f

In [12]: f()
func1

In [13]: !echo "f=lambda: print('func2')" > test_ipython.py

In [14]: from test_ipython import f

In [15]: f()
func2

这里把autoreload的等级配置到2,这表示说所有的函数在运行之前都会被重载一次。也就是说,在运行过程中对函数模块的任何改动都会影响到运行结果。如果想关掉这个重载模块的功能,也只需要把autoreload配置成0就可以了:

In [13]: !echo "f=lambda: print('func2')" > test_ipython.py

In [14]: from test_ipython import f

In [15]: f()
func2

In [16]: %autoreload 0

In [17]: !echo "f=lambda: print('func1')" > test_ipython.py

In [18]: from test_ipython import f

In [19]: f()
func2

完整的参数配置,可以参考官方Doc的内容:

图片来自于参考链接1。

总结概要

在IPython中或者Jupyter Notebook中,一个函数被加载以后,如果这个函数或者模块的主体被修改了,那么就算是在IPython中重新Import一次,在程序执行中也只是去加载内存中的模块,而不是我们修改之后的内容。而IPython支持了一些魔术命令配置,其中autoreload这个魔术命令可以允许我们去配置是否需要重载函数模块。其中不仅包含通用性的全局配置,还支持指向性的配置方法。

版权声明

本文首发链接为:
https://www.cnblogs.com/dechinphy/p/ipy-refresh.html

作者ID:DechinPhy

更多原著文章:
https://www.cnblogs.com/dechinphy/

请博主喝咖啡:
https://www.cnblogs.com/dechinphy/gallery/image/379634.html

参考链接

  1. https://ipython.org/ipython-doc/3/config/extensions/autoreload.html

下面是美团校招的面试真题和答案解析,问的比较细,涉及到的模块有:网络、框架、设计模式(静态代理模式)、手撕代码、MySQL、Java 基础、HashMap 等知识点,接下来我们一起来看吧。
5e595e96d88d480e692a9241899e50e.jpg

1.自我介绍

自我介绍讲明白三个点:

  1. 你是谁?有几年工作经验?哪一届的学生?毕业院线?
  2. 你会啥?
  3. 你最大成就?

2.HTTP属于传输协议的哪一层协议?

HTTP 属于应用层,基于 TCP/IP 实现。

3.TCP和UDP的区别?

  1. TCP 有连接;UDP 无连接。
  2. TCP 可靠;UDP 不可靠。
  3. TCP 基于数据流;UDP 基于数据报。
  4. UDP 比 TCP 更高效。
  5. TCP 发送缓冲区、接收缓冲区;UDP 只有接收缓冲区,没有发送缓冲区。

4.TCP的可靠性传输怎么保证?

保证可靠性的机制:

  1. 确认应答。
  2. 超时重传。
  3. 连接管理(三次握手和四次挥手)。
  4. 拥塞控制。
  5. 流量控制。

5.如果让你设计一个协议,你怎么保证可靠性?

可靠性主要依据:

  1. 连接管理
  2. 确认应答
  3. 超时重传

6.TCP为什么需要三次握手?

TCP 通讯的双方都是全双工的,所以他们彼此要证明自己和对方的发送能力和接收能力。
TCP 如果两次握手不能证明服务器端的发送能力和客户端的接受能力 -> 两次握手。
TCP 至少需要三次握手才能证明彼此的全双工能力。

7.TCP为什么需要四次挥手?

原因有两个:

  1. TCP 本身自带确认应答。
  2. TCP 有接收缓冲区的任务可能没执行完。

8.GET和POST请求的区别?

主要区别:

  1. 隐私性
    :通常情况下 POST 的隐私性高于 GET。
  2. Restful 规范
    :GET 用于查询;POST 用于写入操作。
  3. 传参的大小不同
    :GET 传参有大小限制,而 POST 没有。

9.你刚才说GET请求传输文件大小是有限制是多少?

不同的浏览器是不同。
早期版本通常是 2k~4k,现在是 4K~8k。

10.我看你博客写了很多关于Spring的东西,你可以给我说一下Spring的最重要的两大特性吗?

Spring 核心功能:

  • IoC
  • AOP

11.可以聊一下AOP吗?

思路:

  1. 讲定义
    :AOP 面向切面编程。
  2. 讲实现

    1. 框架添加 AOP 依赖
    2. 定义切面(@Aspect)并且将它放到 IoC 容器
    3. 定义切点 -> @Pointcut 拦截规则
    4. 定义通知 -> 拦截之后的执行方法
  3. 讲原理
    :依靠 AOP 实现:
    1. JDK Proxy
    2. CGLib

12.说一下静态代理的实现类图吗?

UML 类图 -> 设计模式
静态代理 UML 类图:

  1. 接口类
  2. 真实类(被代理的类)
  3. 代理类

13.现在手写一个静态代理?

14.Java基础类型有哪几个?

Java 有以下 8 大数据类型:

  1. 整型
    :byte、short、int、long
  2. 浮点类型
    :float、double
  3. 字符类型
    :char
  4. 布尔类型
    :boolean

15.int是几个字节?

4个字节。

16.int和Integer有啥区别?那你觉得哪一个用起来好,为什么?

区别:

  • int 属于基础数据类型
  • Integer 属于对象。

使用场景:

  • int 使用场景
    :定义类里面的属性。
  • Integer 使用场景

    • 传参
    • 泛型
    • 对象工具方法

17.MySQL的事务隔离级别有哪几个?

  1. 读未提交
    :存在脏读、不可重复读、幻读问题。
  2. 读已提交
    :没有脏读问题了,但是依然存在不可重复读和幻读的问题。
  3. 可重复读(默认隔离级别)
    :没有脏读、不可重复读的问题,但依然存在幻读问题。
  4. 串行化
    :没有脏读、不可重复读、在幻读的问题,但效率较低。

18.为什么读已提交有不可重复读的问题?

读已提交会查询事务已经提交的数据,所以在两次相同的查询中,可能会读到不同的内容,这个问题就是不可重复读的问题。

19.你可以给我模拟一下读已提交发生不可重复读的问题吗?

begin; -- 开启事务
select username from users where id=1; -- 张三
-- 事务2将 users id=1 username='李四'
-- ....
select username from users where id=1; -- 李四

20.写一个SQL语句:查询男生的数量?

select count(*) from users where gender=1;

21.根据写的SQL说一下这个里面索引是谁?生效了吗?

考察的重点:性别是否适合做索引?

无索引,因为性别不适合做索引。

22.HashMap的底层实现原理?

HashMap 使用数组+链表/红黑树来实现的。

23.为什么HashMap要用到链表?

HashMap 会有哈希冲突,HashMap 使用的是链地址法来解决哈希冲突(实现简单、高效),所以使用链表来实现 HashMap 解决哈希冲突。

本文视频解析

飞书链接:
https://r6b93q3exi.feishu.cn/docx/GBJPdy5hIoqTZNxMV8WcBDWUnHb

密码:1p6698&1

企业面试真题视频直播

每周六、周天、周一晚上 8 点,我会在直播间讲解企业面试真题,感兴趣的朋友可以关注一波,一起刷面试真题。
8818c80f9f444f8f5c5451d163755f5.jpg

本文已收录到我的面试小站
www.javacn.site
,其中包含的内容有:Redis、JVM、并发、并发、MySQL、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、设计模式、消息队列等模块。

isa 走位图

在讲
OC->Class
底层类结构之前,先看下下面这张图:

isa走位

通过isa走位图 得出的结论是:
1,类,父类,元类都包含了
isa
,
superclass

2,对象isa指向类对象,类对象的isa指向了元类,元类的
isa
指向了根元类,根元类
isa
指向自己

3,类的
superclass
指向父类,父类的
superclass
指向的根类,根类的
superclass
指向的nil

4,元类的
superclass
指向父元类,父元类
superclass
指向的根元类,根元类
superclass
指向根类,根类
superclass
指向nil

这下又复习了
isa

superclass
走位;那么问题这些类,类对象,元类对象当中的在底层展现的数据结构是怎样呢,这是我需要探索的,于是把源码贴出来展开分析下:

struct objc_class

struct objc_class : objc_object {
    // Class ISA;
    Class superclass; 
    cache_t cache;             // formerly cache pointer and vtable
    class_data_bits_t bits;  
    class_rw_t *data() const {
        return bits.data();
    }
    const class_ro_t *safe_ro() const {
        return bits.safe_ro();
    }
}

从源码没见
isa
属性,其实它继承了
objc_object
,而
objc_object
中有个
isa
,在运行时类图生成中会产生一个
isa
指向
objc_object
这个类图,而
superclass
指向它的父类;根据上面
isa
,
superclass
走位图就知道它的指向关系。

cache_t & class_data_bits_t

cache
方法缓存,这个作用将常调用的方法缓存下来;便于下次直接查找调用,提高查找效率。
它的结构:

struct cache_t {
	struct bucket_t *buckets() const;//存储方法的散列表
	mask_t mask() const;//散列表缓存长度
	mask_t occupied() const;//已缓存方法个数
}
struct class_data_bits_t {
    class_rw_t* data() const;//类信息
}

bits
存储具体类信息,它需要&FAST_DATA_MASK来计算得到类心所有信息,源码如下:

FAST_DATA_MASK 掩码值

imageng

bool has_rw_pointer() const {
	#if FAST_IS_RW_POINTER
	        return (bool)(bits & FAST_IS_RW_POINTER);
	#else
	        class_rw_t *maybe_rw = (class_rw_t *)(bits & FAST_DATA_MASK);
	        return maybe_rw && (bool)(maybe_rw->flags & RW_REALIZED);
	#endif
}

通过源码确实需要这种方式计算能得到类的存储信息;那为什么要用这种方式去处理呢。
比如说我要得到存储在
class_rw_t
类信息信息我只要通过
FAST_DATA_MASK
掩码值就能得到它的地址信息,通过地址信息就能从内存中拿到所有类的存储信息。

那这样我的
FAST_DATA_MASK
掩码值不一样,我通过
&
计算,得到的数据信息也就不一样,不得不说苹果工程师想的周到,而且这种方式不仅isa也是这样,很多地方都用这种方式取值,大大提高访问速度,数据提取效率。

class_rw_t ,class_ro_t,class_rw_ext_t

struct class_rw_t {
     const class_ro_t *ro() const ;
     const method_array_t methods() const ;//如果是类对象:放对象方法,元类:元类对象方法
     
     const property_array_t properties() const;
     const protocol_array_t protocols() const;
     class_rw_ext_t *ext() const;
}
struct class_rw_ext_t {
    method_array_t methods;
    property_array_t properties;
    protocol_array_t protocols;
    uint32_t version;
}

可以看出类的信息具体就存储在
class_rw_t

class_ro_t

class_rw_ext_t
中,

剖析下class_rw_t
先看看
method_array_t

property_array_t

protocol_array_t
源码结构

class property_array_t : 
    public list_array_tt<property_t, property_list_t, RawPtr>
{
    typedef list_array_tt<property_t, property_list_t, RawPtr> Super;

 public:
    property_array_t() : Super() { }
    property_array_t(property_list_t *l) : Super(l) { }
};


class protocol_array_t : 
    public list_array_tt<protocol_ref_t, protocol_list_t, RawPtr>
{
    typedef list_array_tt<protocol_ref_t, protocol_list_t, RawPtr> Super;

 public:
    protocol_array_t() : Super() { }
    protocol_array_t(protocol_list_t *l) : Super(l) { }
};

看完之后,他们都继承
list_array_tt
,那么
list_array_tt
是什么鬼,它数据结构是怎样的,这下在取找下它。源码如下:

template <typename Element, typename List, template<typename> class Ptr>
class list_array_tt {
 protected:
    template <bool authenticated>
    class iteratorImpl {
        const Ptr<List> *lists;
        const Ptr<List> *listsEnd;
    }
        
    using iterator = iteratorImpl<false>;
    using signedIterator = iteratorImpl<true>;

 public:
    list_array_tt() : list(nullptr) { }
    list_array_tt(List *l) : list(l) { }
    list_array_tt(const list_array_tt &other) {
        *this = other;
    }

    void attachLists(List* const * addedLists, uint32_t addedCount) {
        if (addedCount == 0) return;

        if (hasArray()) {
            // many lists -> many lists
            uint32_t oldCount = array()->count;
            uint32_t newCount = oldCount + addedCount;
            array_t *newArray =(array_t*)malloc(array_t::byteSize(newCount));
            newArray->count = newCount;
            array()->count = newCount;

            for (int i = oldCount - 1; i >= 0; i--)
                newArray->lists[i + addedCount] = array()->lists[i];
            for (unsigned i = 0; i < addedCount; i++)
                newArray->lists[i] = addedLists[i];
            free(array());
            setArray(newArray);
            validate();
        }
        else if (!list  &&  addedCount == 1) {
            // 0 lists -> 1 list
            list = addedLists[0];
            validate();
        } 
        else {
            // 1 list -> many lists
            Ptr<List> oldList = list;
            uint32_t oldCount = oldList ? 1 : 0;
            uint32_t newCount = oldCount + addedCount;
            setArray((array_t *)malloc(array_t::byteSize(newCount)));
            array()->count = newCount;
            if (oldList) array()->lists[addedCount] = oldList;
            for (unsigned i = 0; i < addedCount; i++)
                array()->lists[i] = addedLists[i];
            validate();
        }
    }
    
}

我把主要地方拿去出来,可以看到
attachLists
它的目的是将一个或多个列表(
List
类型)附加到某个
list_array_tt
对象中。这个对象可以包含零个、一个或多个列表,这些列表可以是单个指针,也可以是指针数组。函数的输入参数是一个指向
List
指针数组的指针
addedLists
和一个无符号整数
addedCount
,表示要添加的列表数量。

由此我推断它是一个数组,而且是一个二维数组存储的,所有由此得出
class_rw_t

methods

properties

protocols
这几个属性利用二维数组取存储类的方法,协议等信息,而且是可读可写的属性。

那它设计这种二维数组有什么好处呢?当然有好处,它可以动态的给数组里面增加删除方法,很方便我们分类方法的编写完进行存储。

那搞清楚了
class_rw_t
几个重要数据存储信息,那
class_rw_t
它的作用是干什么的呢;


class_rw_t
结构体定义来看;它是在应用运行时,将OC类,分类的信息直接写入到
class_rw_t
结构的数据结构中,在类的方法,协议进行调用时,从里面去读取,然后常调用的方法,又存储在cache_t这个结构体中,可想而知,苹果对OC类的处理,煞费苦心。

struct class_ro_t


class_rw_t
结构体中有个
class_ro_t
结构体,在探索下这个东西做什么的,它的源码如下:

struct class_ro_t {
    WrappedPtr<method_list_t, method_list_t::Ptrauth> baseMethods;
    protocol_list_t * baseProtocols;
    const ivar_list_t * ivars;
    property_list_t *baseProperties;
}

先说说
ivars
这个属性修饰的结构体源码如下:

struct ivar_list_t : entsize_list_tt<ivar_t, ivar_list_t, 0> {
    bool containsIvar(Ivar ivar) const {
        return (ivar >= (Ivar)&*begin()  &&  ivar < (Ivar)&*end());
    }
};

这个貌似只有一个继承
entsize_list_tt
,那在探索下源码:

struct entsize_list_tt {
    uint32_t entsizeAndFlags;
    uint32_t count;
     struct iteratorImpl {
     uint32_t entsize;
        uint32_t index;  // keeping track of this saves a divide in operator-

        using ElementPtr = std::conditional_t<authenticated, Element * __ptrauth(ptrauth_key_process_dependent_data, 1, 0xdead), Element *>;

        ElementPtr element;

        typedef std::random_access_iterator_tag iterator_category;
        typedef Element value_type;
        typedef ptrdiff_t difference_type;
        typedef Element* pointer;
        typedef Element& reference;

        iteratorImpl() { }

        iteratorImpl(const List& list, uint32_t start = 0)
            : entsize(list.entsize())
            , index(start)
            , element(&list.getOrEnd(start))
        { }
     }
}

可以看出这段代码定义了一个结构体
entsize_list_tt
,它内部包含一个嵌套的结构体
iteratorImpl
,用于实现一个迭代器。遍历容器(如列表、数组等)的对象。

到此可以得出
ivars
是一个
ivar_list_t
数组,它存储了类的属性变量信息,那
protocol_list_t
结构体内部也是数组形式构建的。

baseProtocols

baseProperties
这两个属性对类的存储信息只能读取,不能写入。

所以总结的是:从
class_ro_t
结构体定义来看,它存储类的变量,方法,协议信息,而且这个结构体属于类的只读信息,它包含了类的初始信息。

class_rw_ext_t

这个结构体不在过多叙述,简单来说它是基于
class_rw_t
之后为了更好管理oc类的高级特性,比如关联属性等,衍生出来的一个结构体,包括:
method_array_t
,
property_arrat_t
,
protocol_array_t
等定义属性类型

到这里类结构及存储所关联的信息都在这里了;来一张他们关联的结构思维图:

imageng

总结:一开始编译时,程序将类的初始信息放在
class_ro_t
中,当程序运行时,将类的信息合并在一起的时候,它会将
class_ro_t
类的信息合并到
class_rw_t
结构体中去。

struct method_t

为什么要说method_t,因为它不仅在
class_ro_t
有使用,在OC底层其他地方也有使用;比如如下源码:

void method_exchangeImplementations(Method m1Signed, Method m2Signed)
{
    if (!m1Signed  ||  !m2Signed) return;

    method_t *m1 = _method_auth(m1Signed);
    method_t *m2 = _method_auth(m2Signed);

    mutex_locker_t lock(runtimeLock);

    IMP imp1 = m1->imp(false);
    IMP imp2 = m2->imp(false);
    SEL sel1 = m1->name();
    SEL sel2 = m2->name();

    m1->setImp(imp2);
    m2->setImp(imp1);


    // RR/AWZ updates are slow because class is unknown
    // Cache updates are slow because class is unknown
    // fixme build list of classes whose Methods are known externally?

    flushCaches(nil, __func__, [sel1, sel2, imp1, imp2](Class c){
        return c->cache.shouldFlush(sel1, imp1) || c->cache.shouldFlush(sel2, imp2);
    });

    adjustCustomFlagsForMethodChange(nil, m1);
    adjustCustomFlagsForMethodChange(nil, m2);
}

static IMP
_method_setImplementation(Class cls, method_t *m, IMP imp)
{
    lockdebug::assert_locked(&runtimeLock);

    if (!m) return nil;
    if (!imp) return nil;

    IMP old = m->imp(false);
    SEL sel = m->name();

    m->setImp(imp);

    // Cache updates are slow if cls is nil (i.e. unknown)
    // RR/AWZ updates are slow if cls is nil (i.e. unknown)
    // fixme build list of classes whose Methods are known externally?

    flushCaches(cls, __func__, [sel, old](Class c){
        return c->cache.shouldFlush(sel, old);
    });

    adjustCustomFlagsForMethodChange(cls, m);

    return old;
}


方法交换,实现中底层都有用到,我们探索下,先看看
method_t
源码:

struct method_t {

    // The representation of a "big" method. This is the traditional
    // representation of three pointers storing the selector, types
    // and implementation.
    struct big {
        SEL name;
        const char *types;
        MethodListIMP imp;
    };

    // A "big" method, but name is signed. Used for method lists created at runtime.
    struct bigSigned {
        SEL __ptrauth_objc_sel name;
        const char * ptrauth_method_list_types types;
        MethodListIMP imp;
    };

    // ***HACK: This is a TEMPORARY HACK FOR EXCLAVEKIT. It MUST go away.
    // rdar://96885136 (Disallow insecure un-signed big method lists for ExclaveKit)
#if TARGET_OS_EXCLAVEKIT
    struct bigStripped {
        SEL name;
        const char *types;
        MethodListIMP imp;
    };
#endif

}

可以看到这结构体中掐套了多个结构体;在把它简化下:

struct method_t {
    SEL name;//方法名
    const char *types;//包含函数具有参数编码的字符串类型的返回值
    MethodListIMP imp;//函数指针(指向函数地址的指针)
}

SEL
:函数名,没特别的意义;

特点:
1,使用
@selector()

sel_registerName()
获得
2,使用
sel_getName()

NSStringFromSelector()
转成字符串
3,不同类中相同名字方法,对应的方法选择器是相同或相等的

底层代码结构:

/// An opaque type that represents a method selector.
typedef struct objc_selector *SEL;

types
:包含了函数返回值、参数编码的字符串

imageng
imageng

可以看到types在值:
v16@0:8
,可以看出name,types,IMP其实都在class_ro_t结构体中,这样确实证明了之前说的;class_ro_t结构体在运行时存储着类的初始状态数据。

v16@0:8
说明下:

v:方法返回类型,这里说void,

16:第一个参数,

@:id类型第二个参数,

0:第三个参数

:
:selector类型

8:第四个参数

那这种types参数又是什么鬼东西,查下了资料这叫:Type Encoding(类型编码)
怎么证明了,使用如下代码:
imagepng

苹果官网types encoding表格:
imageng

IMP
其实就是指向函数的指针,感觉这个就没有必要讲了。

struct cache_t

cache_t
用于
class
的方法缓存,对
class
常调用的方法缓存下来,提高查询效率,这个上之前都已经说过;接下来看看
bucket_t

struct bucket_t

struct bucket_t {
	cache_key_t _key;//函数名
	IMP _imp;//函数内存地址
}

这种散列表的模型,其实在底层用一个数组展现:

imagng

其实它的内部就是一个一维数组,那可能问了,数组难道它是循环查找吗,其实不然;在它元素超找时,它是拿到你的
函数名 & mask
,而这个
mask
就是
cache_t
结构体中的
mask
值;计算得到函数在
散列表
存储的索引值,在通过索引拿到函数地址,进行执行。

接下来看个事例:

int main(int argc, const char * argv[]) {

    @autoreleasepool {

        Student *stu=[Student new];

        [stu test];

        [stu test];

        [stu test];

        [stu test];

    }

    return 0;

}

如上方法:当首次调用它会去类对象中查找,在方法执行时,他会放入
cache_t
缓存中,当第二次,第三次,第四次时,它就去缓存中查找。

imagpng

当方法执行后;我们看到
_mask
是:3,这个3代表了我类中定义了三个函数;而——
_occupied
是一个随意的值;它其实代表了换存方法的个数。

那如何知道方法有缓存了,再继续往下执行:

imageng

这时候执行完
test02
,
_mask
的值从
3
变成了
7
,说明散列表
bucket_t
做了扩容操作。在这里
bucket_t
元素需要
_mask
个元素,所以最终
bucket_t
从原有的3个元素进行了
2倍
扩容。

在看下方法是否进行缓存:

imageng

可以看见当执行完
[stu test02]
时,数据做了扩容,并且扩容的数据使用
(null)
进行填充。

在看个事例:

imageng

在执行
[stu test]
之前;其实
bucket_t
就3个元素,并且存入了
init
方法;

imageng

当执行完
[stu test]
之后;就存入
test
方法。

但是注意的地方:它在扩容时对之前的缓存进行清除。

image.png

通过查看源码,我们知道了它如何进行清除操作,

imageng

当执行完
[stu test02];

[stu test03];
之后,它先将缓存清空;这时候
init
,
test
方法被清空,
bucket_t
扩容完在存储:
test02

test03
方法。

那问题又来了,它是如何快速定位到方法的,然后执行的?接下来看看代码:

imagepng

可以清楚看见,当我使用
@selector(test03)&stu_cache._mask
就可以得到下标,然后再从
bucket_t
拿到方法。

到这里 class结构,类的方法缓存到此结束了,从上面也可以思考下:如果自己去实现
散列表数组
,是不是思路就跟清晰了。

谢谢大家!青山不改,绿水长流。后会有期!