2021年9月

LRU算法

LRULeast Recently Used 的缩写,即最近最少使用,是系统中常用的一种页面置换算法,其算法思想就是:如果一个页面最近最少使用,那么在将来它被使用的概率就很低。在 App 中,也时常将 LRU 引用为缓存的一种淘汰策略,接下来我们将使用 Swift 来实现一个基于 LRU 的缓存机制。

leetcode题目

运用所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制 。
实现 LRUCache 类:

  • LRUCache(int capacity) 以正整数作为容量 capacity 初始化 LRU 缓存
  • int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
  • void put(int key, int value) 如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字-值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。
  • 要求:get 和 put 操作都要在 O(1)的时间复杂度内完成。

来源:力扣(LeetCode)

分析需求

  1. 要满足 O(1) 时间复杂度,能满足的数据结构就是哈希表。
  2. 最久最未使用,那么数据就应该是有顺序的,数据结构中能满足的是数组和链表。
  3. 数组在分配内存时是连续的,在 Swift 中,扩展一个已分配空间的数组,需要重新分配更大的空间,然后将内容复制到新空间中,这种开销比较大。
  4. 链表的存储是分散的,单向链表要在某个位置上插入或删除一个节点的时间复杂度为 O(n),因为需要先遍历到该位置,而双向链表则不需要遍历,比较灵活。

综上,这个缓存应该是哈希表 + 双向链表的数据结构。

代码实现

缓存的主体是哈希表,双向链表是作为一个顺序的维护。
那么我们可以把链表的节点存储到哈希表中,节点中保存 key和 value ,再通过节点的前趋和后继指针维持双向链表结构。

链表的节点

由于 Swift 的 struct 不允许自身的嵌套,即以下的写法是错误的:

struct Node {
    var prev:Node
}

因此选用 class 来定义链表的节点:

//链表节点
class Node {
    var key,value :Int
    //前趋节点、后继节点
    var prev, next : Node?

    init(_ key:Int,_ value:Int) {
        self.key = key
        self.value = value
    }
    deinit {
        print("Node deinit :",key,"-",value)
    }
}

双向链表的实现

根据单一职责原则,我们将链表的实现和操作单独封装在 DuLinkList 类中,为方便调试,增加 printList() 函数来打印观察。

//双向链表实现
class DuLinkList {
    //首节点、尾节点
    private var header,tail : Node
    
    init() {
        header = Node.init(0, 0)
        tail = Node.init(0, 0)
        header.next = tail
        tail.prev = header
    }
    ///添加到链表首节点
    func addToFirst(_ node:Node){
        node.next = header.next
        header.next?.prev = node
        
        node.prev = header
        header.next = node
    }
    ///删除指定节点
    func removeNode(_ node:Node){
        //首尾节点不删除
        if node.prev == nil || node.next == nil {
            return
        }
        node.prev?.next = node.next
        node.next?.prev = node.prev
        node.prev = nil
        node.next = nil
    }
    ///删除最后一个节点
    func removeLast() -> Node {
        let note = tail.prev!
        removeNode(note)
        return note
    }
    ///打印链表
    func printList(){
        var next = header.next
        while let node = next, node.next != nil {
            print("list note : ",node.key,"-",node.value)
            next = node.next
        }
    }
    ///销毁链表
    private func destroyList(){
        var node:Node? = header
        repeat{
            let next = node?.next
            node?.prev = nil
            node?.next = nil
            node = next
        }while( node != nil)
    }
    
    deinit {
        print("DuLinkList deinit")
        destroyList()
    }
}

值的注意的是:双向链表的节点之间是相互引用的,构成了循环引用,会导致链表无法释放,因此增加 destroyList() 函数来销毁链表,并在 DuLinkList 类中的 deinit函数中调用。

LRUCache 的实现

/// 通过构建哈希表和双向链表来实现LRU算法
 /// 其中,哈希表的 value 是 链表的的节点
class LRUCache {
    //存储容量
    var capacity = 5
    
    //哈希表
    private var cacheTable = Dictionary<Int,Node>()
    //双向链表
    private let linkList = DuLinkList()
    
    init(_ capacity:Int) {
        self.capacity = capacity
    }
    
    /// 将 value 存入哈希表中
    /// 如果表中未存在,则生成节点,并存入表中,并将节点移到链表的首节点
    /// 如果表中已存在,则将更新 value ,并将节点移到链表的首节点
    /// 如果超出存储容量,则删除链表末尾的节点,再存入,并将节点移到链表的首节点
    func put(_ key:Int,_ value:Int){
        if let node = cacheTable[key] {
            node.value = value
            cacheTable[key] = node
            //删除node节点,重新添加到链表首节点
            linkList.removeNode(node)
            linkList.addToFirst(node)
        }else{
            //创建节点
            let note = Node.init(key, value)
            
            if cacheTable.count >= capacity {
                //超出容量,则删除最后一个节点、清除哈希表的 value
                let temp = linkList.removeLast()
                cacheTable[temp.key] = nil
            }
            cacheTable[key] = note
            linkList.addToFirst(note)
        }
    }
    
    /// 从哈希表中取出 value
    /// 如果哈希表中存在value,则将对应的节点放到链表首节点,反之则返回 -1
    func get(_ key:Int) -> Int? {
        guard let node = cacheTable[key] else {
            return -1
        }
        //删除node节点,重新添加到链表首节点
        linkList.removeNode(node)
        linkList.addToFirst(node)

        return node.value
    }
    
    func printTable() {
        print("cache : ",cacheTable)
        linkList.printList()
        print("---------------------")
    }
    
    deinit {
        print("LRUCache deinit")
    }
}

测试

let cache = LRUCache.init(3)
cache.put(1, 1)
cache.printTable()
cache.put(2, 2)
cache.printTable()
cache.put(3, 3)
cache.printTable()
cache.put(4, 4)
cache.printTable()
cache.put(5, 5)
cache.printTable()
print("cache get :",cache.get(3))
cache.printTable()
cache.put(4, 9)
cache.printTable()

测试结果:

cache :  [1: SwiftLang.Node]
list note :  1 - 1
---------------------
cache :  [2: SwiftLang.Node, 1: SwiftLang.Node]
list note :  2 - 2
list note :  1 - 1
---------------------
cache :  [2: SwiftLang.Node, 1: SwiftLang.Node, 3: SwiftLang.Node]
list note :  3 - 3
list note :  2 - 2
list note :  1 - 1
---------------------
Node deinit : 1 - 1
cache :  [2: SwiftLang.Node, 4: SwiftLang.Node, 3: SwiftLang.Node]
list note :  4 - 4
list note :  3 - 3
list note :  2 - 2
---------------------
Node deinit : 2 - 2
cache :  [4: SwiftLang.Node, 5: SwiftLang.Node, 3: SwiftLang.Node]
list note :  5 - 5
list note :  4 - 4
list note :  3 - 3
---------------------
cache get : Optional(3)
cache :  [4: SwiftLang.Node, 5: SwiftLang.Node, 3: SwiftLang.Node]
list note :  3 - 3
list note :  5 - 5
list note :  4 - 4
---------------------
cache :  [4: SwiftLang.Node, 5: SwiftLang.Node, 3: SwiftLang.Node]
list note :  4 - 9
list note :  3 - 3
list note :  5 - 5
---------------------
LRUCache deinit
DuLinkList deinit
Node deinit : 4 - 9
Node deinit : 3 - 3
Node deinit : 5 - 5
Node deinit : 0 - 0
Node deinit : 0 - 0

本文主要讲述以下几个问题:

  1. 什么是block
  2. block有多少种类型
  3. block如何捕获外部变量
  4. 循环引用如何解决
  5. block copy 的不同结果
  6. __block修饰的变量在底层是什么样的

一、 block是什么

查看c++代码

在 xcode 中生成两个文件:TestBlock.h 、TestBlock.m 。其中 TestBlock.m 添加如下代码:

-(void)blockFunc {
    int n = 10;
    int (^myBlock)(int m) = ^(int m){
        return n + m ;
    };
    NSLog(@"%d",myBlock(1));
}

然后通过 clang -rewrite-objc TestBlock.m 命令将 TestBlock.m 转换出 TestBlock.cpp 文件,该文件中是底层如何实现 block 的c++代码。
我们看到,blockFunc 方法在底层代码中变成了 _I_TestBlock_blockFunc 函数:

static void _I_TestBlock_blockFunc(TestBlock * self, SEL _cmd) {
    int n = 10;
    //定义 myBlock
    int (*myBlock)(int m) = ((int (*)(int))&__TestBlock__blockFunc_block_impl_0((void *)__TestBlock__blockFunc_block_func_0, &__TestBlock__blockFunc_block_desc_0_DATA, n));
    //调用 myBlock
    ((int (*)(__block_impl *, int))((__block_impl *)myBlock)->FuncPtr)((__block_impl *)myBlock, 1);
}

再看 __TestBlock__blockFunc_block_impl_0 的定义:

struct __TestBlock__blockFunc_block_impl_0 {
  struct __block_impl impl;  //block 主体
  struct __TestBlock__blockFunc_block_desc_0* Desc;  //block描述
  int n;  //block 捕获的外部变量 n
  //构造函数
  __TestBlock__blockFunc_block_impl_0(void *fp, struct __TestBlock__blockFunc_block_desc_0 *desc, int _n, int flags=0) : n(_n) {
    impl.isa = &_NSConcreteStackBlock;   //初始化为栈 block
    impl.Flags = flags;
    impl.FuncPtr = fp;  //block实现的函数地址
    Desc = desc;
  }
};

block的实现部分被封装在 __TestBlock__blockFunc_block_func_0 函数中,通过 __TestBlock__blockFunc_block_impl_0 构造函数传递到 __block_impl 结构体中:

static int __TestBlock__blockFunc_block_func_0(struct __TestBlock__blockFunc_block_impl_0 *__cself, int m) {
    int n = __cself->n; // bound by copy

    return n + m ;
}
struct __block_impl {
  void *isa;  //isa指针表示block的类型: _NSConcreteStackBlock、_NSConcreteGlobalBlock、_NSConcreteMallocBlock
  int Flags;
  int Reserved;
  void *FuncPtr; //函数指针,__TestBlock__blockFunc_block_func_0 函数
};

在上述代码中,我们发现,myBlock 实质上是 __TestBlock__blockFunc_block_impl_0 结构体的对象。

总结

block 是在底层是一个结构体对象,它封装了 block 的实现代码和捕获的外部变量。

二、block的三种类型

全局block

NSGlobalBlock 类型,位于全局区。
当在 block 内部不使用外部变量,或只是用静态变量和全局变量时,则是全局 block。
示例代码:

static int b = 100;
void (^Block)(void) = ^{
   NSLog(@"== %d",b);
};
NSLog(@"%@",Block);
输出结果为:<__NSGlobalBlock__: 0x10eaca050>

栈block

NSStackBlock 类型,位于栈区。
当使用 __weak 修饰 block 时,被弱引用的 block 没有被 copy 到堆上,则为栈区block。
示例代码:

NSString *str = @"haha";
void (^__weak Block)(void) = ^{
   NSLog(@"-- %@",str);
};
NSLog(@"%@",Block);
输出结果为:<__NSStackBlock__: 0x7ffee1136168>

堆block

NSMallocBlock 类型,位于堆区。
当 block 内部引用了外部变量,且block被强引用时,则为堆区block
示例代码:

NSString *str = @"haha";
void (^Block)(void) = ^{
   NSLog(@"-- %@",str);
};
NSLog(@"%@",Block);
输出结果为:<__NSMallocBlock__: 0x60000227ef10>

理解不同类型的 block

示例代码:

-(void)testBlock{
    NSString *str = @"hello";  //对象类型的 str 局部变量
    int num = 10;  //值类型的 num 局部变量
    void (^__weak weakBlock)(void) ;  //声明 weakBlock
    //代码块
    {
        void (^__weak myBlock)(void) = ^{
            NSLog(@"num : %d",num);
            NSLog(@"str : %@",str);
        };
        NSLog(@"myBlock : %@",myBlock);
        weakBlock = myBlock;
    }
    NSLog(@"weakBlock : %@",weakBlock);
    weakBlock();
}

输出结果:

myBlock : <__NSStackBlock__: 0x7ffeee7b2170>
weakBlock : <__NSStackBlock__: 0x7ffeee7b2170>
num : 10
str : (null)

通过代码与结果分析,我们发现 myBlock 和 weakBlock 都是栈block,并且都指向同一块内存空间 0x7ffeee7b2170 ,而这块内存的生命周期是在 testBlock 函数的作用域之内,即使 myBlock 出了代码块范围,也依然存在于该函数的栈空间中,因此调用 weakBlock() 依然有效。

示例代码:

-(void)testBlock{
    int num = 10;  //值类型的 num 局部变量
    void (^__weak weakBlock)(void) ;  //声明 weakBlock
    //代码块
    {
        void (^myBlock)(void) = ^{
            NSLog(@"num : %d",num);
        };
        NSLog(@"myBlock : %@",myBlock);
        weakBlock = myBlock;
    }
    NSLog(@"weakBlock : %@",weakBlock);
    weakBlock();
}

输出结果:

myBlock : <__NSMallocBlock__: 0x6000034e55f0>
weakBlock : (null)

程序崩溃:EXC_BAD_ACCESS (code=1, address=0x10)
myBlock 去掉__weak修饰后,变成堆 block,而它的生命周期只在代码块中,出了代码块就被释放掉了,因此再调用weakBlock() 程序崩溃。

示例代码:

-(void)testBlock{
    void (^__weak weakBlock)(void) ;  //声明 weakBlock
    //代码块
    {
        void (^myBlock)(void) = ^{
            NSLog(@"hello~");
        };
        NSLog(@"myBlock : %@",myBlock);
        weakBlock = myBlock;
    }
    NSLog(@"weakBlock : %@",weakBlock);
    weakBlock();
}

输出结果:

myBlock : <__NSGlobalBlock__: 0x10e4790d0>
weakBlock : <__NSGlobalBlock__: 0x10e4790d0>
hello~

不使用外部变量后,myBlock 变成全局 block,在内存中类似于单例,一直存在直到程序终止,因此调用 weakBlock()有效。

三、捕获外部变量

在实践中发现,block 对不同类型和不同作用域的外部变量捕获方式是不尽相同的,总结起来如下列表格:

变量类型是否捕获捕获内容
局部变量值类型:捕获值 ; 对象类型: 捕获变量地址和修饰符
静态局部变量捕获变量地址
全局变量/静态全局变量不捕获,直接访问

四、解决 block 循环引用的几个方法

循环引用的问题在 iOS 经常遇到:A对象引用了B对象,B对象中又引用了A对象,导致两个对象都无法释放。
循环引用经常发生在 block 中,下面我们来看个例子:

@interface TestObject()

@property(nonatomic,strong) void(^myBlock)(void);

@end

@implementation TestObject

-(void)blockFunc{
    self.myBlock = ^{
        NSLog(@"self : %@",self);
    };
    self.myBlock();
}

在上述代码中,self 持有了 myBlock ,而在 myBlock 中又捕获了 self,因而构成循环引用,下面我们再来看看解决办法。

1、__weak__strong

常用手法,不再赘述。

2、__block

使用 __block 将 block 中的 self 置为nil,从而使得 block 不再持有 self 。

-(void)blockFunc{
    __block TestObject *temp = self;
    self.myBlock = ^{
        NSLog(@"self:%@",temp);
        temp = nil;
    };
    self.myBlock();
}

3、将强引用对象写成 block 的入参

block 的入参不需要被捕获,因此通过参数传入可以使得 block 不对 self 进行持有。

//修改 myBlock 定义,使其可以传入参数
@property(nonatomic,strong) void(^myBlock)(TestObject *obj);
-(void)blockFunc{
    self.myBlock = ^(TestObject *obj){
        NSLog(@"self:%@",obj);
    };
    self.myBlock(self);
}

五、block copy

copy操作对于不同类型 block 的结果是不一样的,总结起来看表格:

block类型copy结果
NSGlobalBlockcopy无效,结果还是同一个block,不会增加引用计数
NSStackBlockcopy 之后变成 NSMallocBlock,得到新内存,类似深拷贝
NSMallocBlockcopy之后还是 NSMallocBlock,但是同一块内存,会增加引用计数,类似浅拷贝

在 MRC 模式下,以下这段代码将生成一个 NSStackBlock,然后通过 copy 变成 NSMallocBlock

int n = 0;
void (^block)(void) = ^{
    NSLog(@"n = %d",n);
};
NSLog(@"b : %@",block);
block = [block copy];
NSLog(@"bb : %@",block);  

在 MRC 模式下输出为:

b : <__NSStackBlock__: 0x7ffee701c158>
bb : <__NSMallocBlock__: 0x60000178b000>

在 ARC模式下输出为:

b : <__NSMallocBlock__: 0x600003854420>
bb : <__NSMallocBlock__: 0x600003854420>

通过上面的比较,我们发现,block 创建时是 NSStackBlock ,而在 ARC 模式下,block创建时就执行了copy操作变成了 NSMallocBlock,并且 NSMallocBlock copy 之后还是同一块内存。

六、__block修饰的变量

__block修饰的变量在底层是一个结构体,我们来看一下代码:

__block int number = 100;
    void (^myBlock)(void) = ^{
        number = 101;
    };
    number = 2;

通过 clang rewrite 命令转换为 c++代码:

// __block number 在底层是一个名为 __Block_byref_number_0 的结构体
__attribute__((__blocks__(byref))) __Block_byref_number_0 number = {(void*)0,(__Block_byref_number_0 *)&number, 0, sizeof(__Block_byref_number_0), 100};
// myBlock 定义
    void (*myBlock)(void) = ((void (*)())&__TestBlock__blockFunc_block_impl_0((void *)__TestBlock__blockFunc_block_func_0, &__TestBlock__blockFunc_block_desc_0_DATA, (__Block_byref_number_0 *)&number, 570425344));

//block外部修改number的值为 2
    (number.__forwarding->number) = 2;  

__Block_byref_number_0 结构体:

struct __Block_byref_number_0 {
  void *__isa;
__Block_byref_number_0 *__forwarding;
 int __flags;
 int __size;
 int number;
};

从上述代码中,我们可以看到,在block内部和外部,对 number 的赋值都是通过对 number.__forwarding->number 赋值完成的,那么我们来了解一下其缘由。

__forwarding 指针

还是使用上面 __block int number = 100; 的例子,__forwarding指针是 __Block_byref_number_0 结构体的成员,在栈block中,它指向了__Block_byref_number_0 结构体自身(如图1),当栈block 被 copy 变成堆block时,栈区block中的 __forwarding指针指向了堆区block 的__Block_byref_number_0 结构体,而堆区block 的__forwarding指针还是指向结构体自身(如图2)。
图1:
图1
图2:
图2

通过上述代码,我们看到对 number 复制或访问都是通过__forwarding 指针完成的,这样做的好处是无论是栈block还是堆block,通过__forwarding 指针,都能够正确的访问到 __block 修饰的变量。

总结

1、当没有应用外部变量时是全局Block。
2、当 __weak 修饰Block 时是栈区Block
3、其他情况属于堆区Block
4、Block的本质是一个封装了实现和应用外部变量的结构体
5、__block 修饰的变量会被封装成一个结构体,并且Block结构体中有一个指针始终指向它,所以可以修改变量。